From ff52d0d2e807c6d15e2cc983c5302f115f3c5534 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 08:59:15 -0500 Subject: [PATCH 01/50] deps(libtmux[~=0.55.0]): Bump from ~=0.53.0 for logging, set_title, tmux_bin why: Pick up three libtmux releases (0.53.1, 0.54.0, 0.55.0) bringing structured logging, new Pane API, configurable tmux binary, and several bug fixes that improve error propagation. what: - Bump libtmux dependency specifier ~=0.53.0 -> ~=0.55.0 in pyproject.toml - Update uv.lock (resolved 0.53.1 -> 0.55.0) libtmux 0.55.0 (2026-03-07): - Pane.set_title() wraps select-pane -T; pane_title added to format queries - Server(tmux_bin=) threads custom binary through all commands and version checks - Pre-execution DEBUG logging in tmux_cmd with structured extra - TmuxCommandNotFound raised consistently for invalid tmux_bin paths libtmux 0.54.0 (2026-03-07): - Structured lifecycle logging (INFO) across Server, Session, Window, Pane - NullHandler in __init__.py; lazy %s formatting; isEnabledFor guards - Window.rename_window() now raises on failure instead of swallowing - Server.kill() captures stderr, handles "no server running" gracefully - Server.new_session() checks kill-session stderr - Session.kill_window() target formatting fix (session_name, not window_name) libtmux 0.53.1 (2026-02-18): - Fix race condition in new_session() by avoiding list-sessions query Release: https://github.com/tmux-python/libtmux/releases/tag/v0.55.0 CHANGES: https://github.com/tmux-python/tmuxp/blob/v1.64.1/CHANGES#tmuxp-1641-2026-03-08 Changelog: https://libtmux.git-pull.com/history.html#libtmux-0-55-0-2026-03-07 --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0fbcc71110..fd514ee180 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ include = [ { path = "conftest.py", format = "sdist" }, ] dependencies = [ - "libtmux~=0.53.0", + "libtmux~=0.55.0", "colorama>=0.3.9", "PyYAML>=6.0" ] diff --git a/uv.lock b/uv.lock index 39ff327f7d..0e77745341 100644 --- a/uv.lock +++ b/uv.lock @@ -510,11 +510,11 @@ wheels = [ [[package]] name = "libtmux" -version = "0.53.1" +version = "0.55.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8d/99/0ac0f60d5b93a8a291be02ed1f3fcf70ff50c0526fa9a99eb462d74354b1/libtmux-0.53.1.tar.gz", hash = "sha256:0d9ca4bcf5c0fb7d7a1e4ce0c0cdcbcd7fb354a66819c3d60ccea779d83eac83", size = 413660, upload-time = "2026-02-19T00:44:24.761Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/85/99932ac9ddb90821778f8cabe32b81bbbec280dd1a14a457c512693fb11b/libtmux-0.55.0.tar.gz", hash = "sha256:cdc4aa564b2325618d73d57cb0d7d92475d02026dba2b96a94f87ad328e7e79d", size = 420859, upload-time = "2026-03-08T00:57:55.788Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/58/4a7195e692a4aedf88f3f2701db5a06e730447b504747b19385eb141b718/libtmux-0.53.1-py3-none-any.whl", hash = "sha256:8db49f32a1d5ac0f44ed6b76558c7a3baba701fbbbf6c66a31045f7f779b71a0", size = 78395, upload-time = "2026-02-19T00:44:22.961Z" }, + { url = "https://files.pythonhosted.org/packages/8b/34/b11ab24abb78c73a1b82f6471c2d71bdd1bf2c8f30768ed2f26f1dddc083/libtmux-0.55.0-py3-none-any.whl", hash = "sha256:4b746533856e022c759e5c5cae97f4932e85dae316a2afd4391d6d0e891d6ab8", size = 80094, upload-time = "2026-03-08T00:57:54.141Z" }, ] [[package]] @@ -1463,7 +1463,7 @@ testing = [ [package.metadata] requires-dist = [ { name = "colorama", specifier = ">=0.3.9" }, - { name = "libtmux", specifier = "~=0.53.0" }, + { name = "libtmux", specifier = "~=0.55.0" }, { name = "pyyaml", specifier = ">=6.0" }, ] From 094800f484c67993eb790c2efea3c2731df59869 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 09:03:57 -0500 Subject: [PATCH 02/50] docs(CHANGES) libtmux ~=0.55.0 bump with logging, set_title, tmux_bin why: Document the dependency bump for the upcoming release. what: - Add Development entry for libtmux ~=0.53.0 -> ~=0.55.0 bump - Summarize key upstream changes across three releases --- CHANGES | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGES b/CHANGES index 93b4431235..2f731110b0 100644 --- a/CHANGES +++ b/CHANGES @@ -35,6 +35,14 @@ $ pipx install --suffix=@next 'tmuxp' --pip-args '\--pre' --force _Notes on the upcoming release will go here._ +### Breaking Changes + +#### **libtmux** minimum bumped from `~=0.53.0` to `~=0.55.0` (#1019) + + Picks up three releases: 0.53.1 (race condition fix in `new_session()`), + 0.54.0 (structured lifecycle logging, error propagation fixes), and + 0.55.0 (`Pane.set_title()`, `Server(tmux_bin=)`, pre-execution DEBUG logging). + ## tmuxp 1.64.1 (2026-03-08) ### Bug fixes From 54aadae523d4250a0ae90851cc640b3253f46baa Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 11:08:42 -0500 Subject: [PATCH 03/50] Tag v1.65.0 (libtmux v0.55.0) --- CHANGES | 4 +++- pyproject.toml | 2 +- src/tmuxp/__about__.py | 2 +- uv.lock | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGES b/CHANGES index a82d848c37..95d8c3e57e 100644 --- a/CHANGES +++ b/CHANGES @@ -27,7 +27,7 @@ $ pipx install --suffix=@next 'tmuxp' --pip-args '\--pre' --force // Usage: tmuxp@next load yoursession ``` -## tmuxp 1.65.0 (Yet to be released) +## tmuxp 1.66.0 (Yet to be released) @@ -35,6 +35,8 @@ $ pipx install --suffix=@next 'tmuxp' --pip-args '\--pre' --force _Notes on the upcoming release will go here._ +## tmuxp 1.65.0 (2026-03-08) + ### Breaking Changes #### **libtmux** minimum bumped from `~=0.53.0` to `~=0.55.0` (#1019) diff --git a/pyproject.toml b/pyproject.toml index 0b11c4f57d..4e908892de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tmuxp" -version = "1.64.2" +version = "1.65.0" description = "Session manager for tmux, which allows users to save and load tmux sessions through simple configuration files." requires-python = ">=3.10,<4.0" authors = [ diff --git a/src/tmuxp/__about__.py b/src/tmuxp/__about__.py index aaf0ffb342..adf89c7b0c 100644 --- a/src/tmuxp/__about__.py +++ b/src/tmuxp/__about__.py @@ -4,7 +4,7 @@ __title__ = "tmuxp" __package_name__ = "tmuxp" -__version__ = "1.64.2" +__version__ = "1.65.0" __description__ = "tmux session manager" __email__ = "tony@git-pull.com" __author__ = "Tony Narlock" diff --git a/uv.lock b/uv.lock index 334996cdc0..0542d5e6f4 100644 --- a/uv.lock +++ b/uv.lock @@ -1379,7 +1379,7 @@ wheels = [ [[package]] name = "tmuxp" -version = "1.64.2" +version = "1.65.0" source = { editable = "." } dependencies = [ { name = "colorama" }, From 69a886c0864c4f98afcd4af42ef521e30f31ad4f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 14:42:27 -0500 Subject: [PATCH 04/50] refactor(logging[infra]): add structured logging infrastructure to all modules why: Enable structured logging with `extra` context for filtering, testing, and aggregation. Library modules need NullHandler per Python best practices. what: - Add `logging.getLogger(__name__)` to every module - Add NullHandler in library `__init__.py` - Add TmuxpLoggerAdapter with process() override for Python <3.13 compat - Simplify tmuxp_echo to pure print wrapper (decoupled from logging) - Add setup_log_file() for centralized --log-file handler setup - Fix setup_logger to target "tmuxp" logger, skip NullHandler check - Change default CLI log level from INFO to WARNING - Fix timestamp format bug: %H:%m:%S -> %H:%M:%S - Add future annotations and fix import ordering in _compat.py --- src/tmuxp/__about__.py | 4 ++ src/tmuxp/__init__.py | 5 ++ src/tmuxp/_compat.py | 16 +++-- src/tmuxp/_internal/__init__.py | 6 ++ src/tmuxp/_internal/private_path.py | 3 + src/tmuxp/_internal/types.py | 3 + src/tmuxp/cli/__init__.py | 6 +- src/tmuxp/cli/_colors.py | 4 ++ src/tmuxp/cli/_formatter.py | 3 + src/tmuxp/cli/load.py | 10 ++- src/tmuxp/cli/search.py | 3 + src/tmuxp/exc.py | 4 ++ src/tmuxp/log.py | 104 ++++++++++++++++++--------- src/tmuxp/types.py | 3 + src/tmuxp/workspace/__init__.py | 6 ++ src/tmuxp/workspace/constants.py | 4 ++ tests/test_log.py | 105 ++++++++++++++++++++++++++++ 17 files changed, 242 insertions(+), 47 deletions(-) create mode 100644 tests/test_log.py diff --git a/src/tmuxp/__about__.py b/src/tmuxp/__about__.py index adf89c7b0c..cb95133689 100644 --- a/src/tmuxp/__about__.py +++ b/src/tmuxp/__about__.py @@ -2,6 +2,10 @@ from __future__ import annotations +import logging + +logger = logging.getLogger(__name__) + __title__ = "tmuxp" __package_name__ = "tmuxp" __version__ = "1.65.0" diff --git a/src/tmuxp/__init__.py b/src/tmuxp/__init__.py index 25c46f4d21..78e2118ea5 100644 --- a/src/tmuxp/__init__.py +++ b/src/tmuxp/__init__.py @@ -6,6 +6,8 @@ from __future__ import annotations +import logging + from . import cli, util from .__about__ import ( __author__, @@ -17,3 +19,6 @@ __title__, __version__, ) + +logger = logging.getLogger(__name__) +logger.addHandler(logging.NullHandler()) diff --git a/src/tmuxp/_compat.py b/src/tmuxp/_compat.py index ca83962641..7888a50b46 100644 --- a/src/tmuxp/_compat.py +++ b/src/tmuxp/_compat.py @@ -1,18 +1,26 @@ -# flake8: NOQA +from __future__ import annotations + +import logging import sys +logger = logging.getLogger(__name__) + PY3 = sys.version_info[0] == 3 PYMINOR = sys.version_info[1] PYPATCH = sys.version_info[2] -_identity = lambda x: x + +def _identity(x: object) -> object: + """Return *x* unchanged — used as a no-op decorator.""" + return x + if PY3 and PYMINOR >= 7: - breakpoint = breakpoint + breakpoint = breakpoint # noqa: A001 else: import pdb - breakpoint = pdb.set_trace + breakpoint = pdb.set_trace # noqa: A001 implements_to_string = _identity diff --git a/src/tmuxp/_internal/__init__.py b/src/tmuxp/_internal/__init__.py index 01dccbcfcb..baae40fd2c 100644 --- a/src/tmuxp/_internal/__init__.py +++ b/src/tmuxp/_internal/__init__.py @@ -1 +1,7 @@ """Internal APIs for tmuxp.""" + +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) diff --git a/src/tmuxp/_internal/private_path.py b/src/tmuxp/_internal/private_path.py index 2ab8a998ae..8fa0cec972 100644 --- a/src/tmuxp/_internal/private_path.py +++ b/src/tmuxp/_internal/private_path.py @@ -6,10 +6,13 @@ from __future__ import annotations +import logging import os import pathlib import typing as t +logger = logging.getLogger(__name__) + if t.TYPE_CHECKING: PrivatePathBase = pathlib.Path else: diff --git a/src/tmuxp/_internal/types.py b/src/tmuxp/_internal/types.py index a3521f5832..41498cebd0 100644 --- a/src/tmuxp/_internal/types.py +++ b/src/tmuxp/_internal/types.py @@ -12,9 +12,12 @@ from __future__ import annotations +import logging import typing as t from typing import TypedDict +logger = logging.getLogger(__name__) + if t.TYPE_CHECKING: import sys diff --git a/src/tmuxp/cli/__init__.py b/src/tmuxp/cli/__init__.py index b05708321f..860a9200cb 100644 --- a/src/tmuxp/cli/__init__.py +++ b/src/tmuxp/cli/__init__.py @@ -181,9 +181,9 @@ def create_parser() -> argparse.ArgumentParser: "--log-level", action="store", metavar="log-level", - default="info", + default="warning", choices=["debug", "info", "warning", "error", "critical"], - help='log level (debug, info, warning, error, critical) (default "info")', + help='log level (debug, info, warning, error, critical) (default "warning")', ) parser.add_argument( "--color", @@ -297,7 +297,7 @@ def cli(_args: list[str] | None = None) -> None: parser = create_parser() args = parser.parse_args(_args, namespace=ns) - setup_logger(logger=logger, level=args.log_level.upper()) + setup_logger(level=args.log_level.upper()) if args.subparser_name is None: parser.print_help() diff --git a/src/tmuxp/cli/_colors.py b/src/tmuxp/cli/_colors.py index 9932218fb6..2513d118a8 100644 --- a/src/tmuxp/cli/_colors.py +++ b/src/tmuxp/cli/_colors.py @@ -9,6 +9,8 @@ from __future__ import annotations +import logging + from tmuxp._internal.colors import ( ColorMode, Colors, @@ -20,6 +22,8 @@ unstyle, ) +logger = logging.getLogger(__name__) + __all__ = [ "ColorMode", "Colors", diff --git a/src/tmuxp/cli/_formatter.py b/src/tmuxp/cli/_formatter.py index 9dfceaf29e..d4efb9ff47 100644 --- a/src/tmuxp/cli/_formatter.py +++ b/src/tmuxp/cli/_formatter.py @@ -13,9 +13,12 @@ from __future__ import annotations import argparse +import logging import re import typing as t +logger = logging.getLogger(__name__) + # Options that expect a value (set externally or via --option=value) OPTIONS_EXPECTING_VALUE = frozenset( { diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index 3e6edbd2b7..fd88efcf10 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -23,6 +23,8 @@ from ._colors import ColorMode, Colors, build_description, get_color_mode from .utils import prompt_choices, prompt_yes_no, tmuxp_echo +logger = logging.getLogger(__name__) + LOAD_DESCRIPTION = build_description( """ Load tmuxp workspace file(s) and create or attach to a tmux session. @@ -74,6 +76,7 @@ class CLILoadNamespace(argparse.Namespace): colors: CLIColorsLiteral | None color: CLIColorModeLiteral log_file: str | None + log_level: str def load_plugins( @@ -590,12 +593,7 @@ def command_load( cli_colors = Colors(get_color_mode(args.color)) if args.log_file: - logfile_handler = logging.FileHandler(args.log_file) - logfile_handler.setFormatter(log.LogFormatter()) - # Add handler to tmuxp root logger to capture all tmuxp log messages - tmuxp_logger = logging.getLogger("tmuxp") - tmuxp_logger.setLevel(logging.INFO) # Ensure logger level allows INFO - tmuxp_logger.addHandler(logfile_handler) + log.setup_log_file(args.log_file, args.log_level) if args.workspace_files is None or len(args.workspace_files) == 0: tmuxp_echo(cli_colors.error("Enter at least one config")) diff --git a/src/tmuxp/cli/search.py b/src/tmuxp/cli/search.py index 93368fd04f..3be4cb1974 100644 --- a/src/tmuxp/cli/search.py +++ b/src/tmuxp/cli/search.py @@ -25,6 +25,7 @@ import argparse import json +import logging import pathlib import re import typing as t @@ -39,6 +40,8 @@ from ._colors import Colors, build_description, get_color_mode from ._output import OutputFormatter, get_output_mode +logger = logging.getLogger(__name__) + if t.TYPE_CHECKING: from typing import TypeAlias diff --git a/src/tmuxp/exc.py b/src/tmuxp/exc.py index 525599270f..545038d7ca 100644 --- a/src/tmuxp/exc.py +++ b/src/tmuxp/exc.py @@ -2,10 +2,14 @@ from __future__ import annotations +import logging + from libtmux._internal.query_list import ObjectDoesNotExist from ._compat import implements_to_string +logger = logging.getLogger(__name__) + class TmuxpException(Exception): """Base Exception for Tmuxp Errors.""" diff --git a/src/tmuxp/log.py b/src/tmuxp/log.py index e4429eda6a..15367d5310 100644 --- a/src/tmuxp/log.py +++ b/src/tmuxp/log.py @@ -4,12 +4,13 @@ from __future__ import annotations import logging +import sys import time import typing as t from colorama import Fore, Style -from tmuxp._internal.colors import unstyle +logger = logging.getLogger(__name__) LEVEL_COLORS = { "DEBUG": Fore.BLUE, # Blue @@ -19,14 +20,23 @@ "CRITICAL": Fore.RED, } -LOG_LEVELS = { - "CRITICAL": 50, - "ERROR": 40, - "WARNING": 30, - "INFO": 20, - "DEBUG": 10, - "NOTSET": 0, -} + +class TmuxpLoggerAdapter(logging.LoggerAdapter): # type: ignore[type-arg] + """LoggerAdapter that merges extra dictionary on Python < 3.13. + + Follows the portable pattern to avoid repeating the same `extra` on every call + while preserving the ability to add per-call `extra` kwargs. + """ + + def process( + self, msg: t.Any, kwargs: t.MutableMapping[str, t.Any] + ) -> tuple[t.Any, t.MutableMapping[str, t.Any]]: + """Merge extra dictionary on Python < 3.13.""" + extra = dict(self.extra) if self.extra else {} + if "extra" in kwargs: + extra.update(kwargs["extra"]) + kwargs["extra"] = extra + return msg, kwargs def setup_logger( @@ -43,10 +53,17 @@ def setup_logger( logger instance for tmuxp """ if not logger: # if no logger exists, make one - logger = logging.getLogger() + logger = logging.getLogger("tmuxp") + + has_handlers = any(not isinstance(h, logging.NullHandler) for h in logger.handlers) - if not logger.handlers: # setup logger handlers - logger.setLevel(level) + if not has_handlers: # setup logger handlers + channel = logging.StreamHandler() + formatter = DebugLogFormatter() if level == "DEBUG" else LogFormatter() + channel.setFormatter(formatter) + logger.addHandler(channel) + + logger.setLevel(level) def set_style( @@ -126,7 +143,7 @@ def format(self, record: logging.LogRecord) -> str: except Exception as e: record.message = f"Bad message ({e!r}): {record.__dict__!r}" - date_format = "%H:%m:%S" + date_format = "%H:%M:%S" formatting = self.converter(record.created) record.asctime = time.strftime(date_format, formatting) @@ -204,42 +221,61 @@ class DebugLogFormatter(LogFormatter): template = debug_log_template -# Use tmuxp root logger so messages propagate to CLI handlers -_echo_logger = logging.getLogger("tmuxp") +def setup_log_file(log_file: str, level: str = "INFO") -> None: + """Attach a file handler to the tmuxp logger. + + Parameters + ---------- + log_file : str + Path to the log file. + level : str + Log level name (e.g. "DEBUG", "INFO"). Selects formatter and sets + handler filtering level. + + Examples + -------- + >>> import tempfile, os, logging + >>> f = tempfile.NamedTemporaryFile(suffix=".log", delete=False) + >>> f.close() + >>> setup_log_file(f.name, level="INFO") + >>> tmuxp_logger = logging.getLogger("tmuxp") + >>> tmuxp_logger.handlers = [ + ... h for h in tmuxp_logger.handlers if not isinstance(h, logging.FileHandler) + ... ] + >>> os.unlink(f.name) + """ + handler = logging.FileHandler(log_file) + formatter = DebugLogFormatter() if level.upper() == "DEBUG" else LogFormatter() + handler.setFormatter(formatter) + handler_level = getattr(logging, level.upper()) + handler.setLevel(handler_level) + tmuxp_logger = logging.getLogger("tmuxp") + tmuxp_logger.addHandler(handler) + if tmuxp_logger.level == logging.NOTSET or tmuxp_logger.level > handler_level: + tmuxp_logger.setLevel(handler_level) def tmuxp_echo( message: str | None = None, - log_level: str = "INFO", - style_log: bool = False, + file: t.TextIO | None = None, ) -> None: - """Combine logging.log and print for CLI output. + """Print user-facing CLI output. Parameters ---------- message : str | None - Message to log and print. If None, does nothing. - log_level : str - Log level to use (DEBUG, INFO, WARNING, ERROR, CRITICAL). - Default is INFO. - style_log : bool - If True, preserve ANSI styling in log output. - If False, strip ANSI codes from log output. Default is False. + Message to print. If None, does nothing. + file : t.TextIO | None + Output stream. Defaults to sys.stdout. Examples -------- - >>> tmuxp_echo("Session loaded") # doctest: +ELLIPSIS + >>> tmuxp_echo("Session loaded") Session loaded - >>> tmuxp_echo("Warning message", log_level="WARNING") # doctest: +ELLIPSIS + >>> tmuxp_echo("Warning message") Warning message """ if message is None: return - - if style_log: - _echo_logger.log(LOG_LEVELS[log_level], message) - else: - _echo_logger.log(LOG_LEVELS[log_level], unstyle(message)) - - print(message) + print(message, file=file or sys.stdout) diff --git a/src/tmuxp/types.py b/src/tmuxp/types.py index 4d267ae2ee..4aa5031239 100644 --- a/src/tmuxp/types.py +++ b/src/tmuxp/types.py @@ -9,8 +9,11 @@ from __future__ import annotations +import logging import typing as t +logger = logging.getLogger(__name__) + if t.TYPE_CHECKING: from os import PathLike diff --git a/src/tmuxp/workspace/__init__.py b/src/tmuxp/workspace/__init__.py index ac87e57f62..2b3996b050 100644 --- a/src/tmuxp/workspace/__init__.py +++ b/src/tmuxp/workspace/__init__.py @@ -1 +1,7 @@ """tmuxp workspace functionality.""" + +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) diff --git a/src/tmuxp/workspace/constants.py b/src/tmuxp/workspace/constants.py index 48ecc9c1f6..4a39082b6d 100644 --- a/src/tmuxp/workspace/constants.py +++ b/src/tmuxp/workspace/constants.py @@ -2,4 +2,8 @@ from __future__ import annotations +import logging + +logger = logging.getLogger(__name__) + VALID_WORKSPACE_DIR_FILE_EXTENSIONS = [".yaml", ".yml", ".json"] diff --git a/tests/test_log.py b/tests/test_log.py new file mode 100644 index 0000000000..b916b37b94 --- /dev/null +++ b/tests/test_log.py @@ -0,0 +1,105 @@ +"""Tests for tmuxp.log module.""" + +from __future__ import annotations + +import logging +import sys + +import pytest + +from tmuxp.log import ( + LEVEL_COLORS, + DebugLogFormatter, + LogFormatter, + tmuxp_echo, +) + + +def test_level_colors_no_colorama() -> None: + """LEVEL_COLORS must be raw ANSI escape strings, not colorama objects.""" + for level, code in LEVEL_COLORS.items(): + assert code.startswith("\033["), ( + f"LEVEL_COLORS[{level!r}] should start with ANSI ESC, got {code!r}" + ) + + +def test_log_formatter_format_plain_text() -> None: + """LogFormatter.format() produces plain text without ANSI when unstylized.""" + formatter = LogFormatter() + record = logging.LogRecord( + name="tmuxp", + level=logging.INFO, + pathname="", + lineno=0, + msg="test message", + args=(), + exc_info=None, + ) + output = formatter.format(record) + assert "test message" in output + assert "\033[" not in output + + +def test_debug_log_formatter_format_smoke() -> None: + """DebugLogFormatter.format() runs without error.""" + formatter = DebugLogFormatter() + record = logging.LogRecord( + name="tmuxp", + level=logging.DEBUG, + pathname="", + lineno=42, + msg="debug message", + args=(), + exc_info=None, + ) + output = formatter.format(record) + assert "debug message" in output + + +def test_timestamp_format_has_minutes() -> None: + """Timestamp format must use %M (minutes), not %m (month).""" + formatter = LogFormatter() + record = logging.LogRecord( + name="tmuxp", + level=logging.INFO, + pathname="", + lineno=0, + msg="ts check", + args=(), + exc_info=None, + ) + formatter.format(record) + # asctime is set during format(); if %m were used, seconds portion would + # show month (01-12) instead of minutes (00-59) — we can't easily + # distinguish that directly, so just verify the format string constant. + # Inspect the source: date_format in LogFormatter.format is "%H:%M:%S" + import inspect + + import tmuxp.log as log_module + + src = inspect.getsource(log_module.LogFormatter.format) + assert '"%H:%M:%S"' in src, "Timestamp format must be %H:%M:%S (M = minutes)" + + +def test_tmuxp_echo_default_stdout(capsys: pytest.CaptureFixture[str]) -> None: + """tmuxp_echo writes to stdout by default.""" + tmuxp_echo("hello stdout") + captured = capsys.readouterr() + assert captured.out == "hello stdout\n" + assert captured.err == "" + + +def test_tmuxp_echo_to_stderr(capsys: pytest.CaptureFixture[str]) -> None: + """tmuxp_echo writes to stderr when file=sys.stderr.""" + tmuxp_echo("hello stderr", file=sys.stderr) + captured = capsys.readouterr() + assert captured.err == "hello stderr\n" + assert captured.out == "" + + +def test_tmuxp_echo_none_is_no_op(capsys: pytest.CaptureFixture[str]) -> None: + """tmuxp_echo(None) produces no output.""" + tmuxp_echo(None) + captured = capsys.readouterr() + assert captured.out == "" + assert captured.err == "" From 1c828c48a754968f96b9861774c05d5df56a0adf Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 14:46:15 -0500 Subject: [PATCH 05/50] feat(logging[extra]): add structured log calls with extra context across all modules why: Structured `extra` keys (tmux_session, tmux_window, tmux_pane, tmux_config_path) enable filtering, aggregation, and test assertions on log records rather than string matching. what: - Add structured DEBUG/INFO/WARNING/ERROR log calls to workspace, CLI, and utility modules with appropriate extra keys - Route all raw print() calls through tmuxp_echo() in CLI commands - Fix get_pane() exception catch type to match sibling methods - Change before_script failure log from DEBUG to ERROR - Remove catch-log-reraise in plugin version check --- src/tmuxp/_internal/config_reader.py | 4 ++++ src/tmuxp/cli/convert.py | 7 ++++-- src/tmuxp/cli/edit.py | 6 +++++- src/tmuxp/cli/freeze.py | 16 ++++++++------ src/tmuxp/cli/import_config.py | 7 ++++++ src/tmuxp/cli/load.py | 32 ++++++++++++++++++---------- src/tmuxp/cli/shell.py | 8 +++++-- src/tmuxp/cli/utils.py | 5 ++++- src/tmuxp/plugin.py | 4 ++++ src/tmuxp/shell.py | 19 ++++++++++------- src/tmuxp/util.py | 16 +++++++++++--- src/tmuxp/workspace/builder.py | 30 ++++++++++++++++++++++++++ src/tmuxp/workspace/finders.py | 22 ++++++++++++++++--- src/tmuxp/workspace/freezer.py | 12 +++++++++++ src/tmuxp/workspace/importers.py | 17 +++++++++++++++ src/tmuxp/workspace/loader.py | 10 +++++++++ src/tmuxp/workspace/validation.py | 12 +++++++++++ 17 files changed, 190 insertions(+), 37 deletions(-) diff --git a/src/tmuxp/_internal/config_reader.py b/src/tmuxp/_internal/config_reader.py index 3c667bbceb..6da248dea7 100644 --- a/src/tmuxp/_internal/config_reader.py +++ b/src/tmuxp/_internal/config_reader.py @@ -3,11 +3,14 @@ from __future__ import annotations import json +import logging import pathlib import typing as t import yaml +logger = logging.getLogger(__name__) + if t.TYPE_CHECKING: from typing import TypeAlias @@ -106,6 +109,7 @@ def _from_file(cls, path: pathlib.Path) -> dict[str, t.Any]: {'session_name': 'my session'} """ assert isinstance(path, pathlib.Path) + logger.debug("loading config", extra={"tmux_config_path": str(path)}) content = path.open(encoding="utf-8").read() if path.suffix in {".yaml", ".yml"}: diff --git a/src/tmuxp/cli/convert.py b/src/tmuxp/cli/convert.py index 97d2d8cd25..a92cfebbfa 100644 --- a/src/tmuxp/cli/convert.py +++ b/src/tmuxp/cli/convert.py @@ -3,6 +3,7 @@ from __future__ import annotations import locale +import logging import os import pathlib import typing as t @@ -13,7 +14,9 @@ from tmuxp.workspace.finders import find_workspace_file, get_workspace_dir from ._colors import Colors, build_description, get_color_mode -from .utils import prompt_yes_no +from .utils import prompt_yes_no, tmuxp_echo + +logger = logging.getLogger(__name__) CONVERT_DESCRIPTION = build_description( """ @@ -130,7 +133,7 @@ def command_convert( new_workspace, encoding=locale.getpreferredencoding(False), ) - print( # NOQA: T201 RUF100 + tmuxp_echo( colors.success("New workspace file saved to ") + colors.info(str(PrivatePath(newfile))) + ".", diff --git a/src/tmuxp/cli/edit.py b/src/tmuxp/cli/edit.py index 006ad6bb12..7308f2eba7 100644 --- a/src/tmuxp/cli/edit.py +++ b/src/tmuxp/cli/edit.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import os import subprocess import typing as t @@ -10,6 +11,9 @@ from tmuxp.workspace.finders import find_workspace_file from ._colors import Colors, build_description, get_color_mode +from .utils import tmuxp_echo + +logger = logging.getLogger(__name__) EDIT_DESCRIPTION = build_description( """ @@ -59,7 +63,7 @@ def command_edit( workspace_file = find_workspace_file(workspace_file) sys_editor = os.environ.get("EDITOR", "vim") - print( # NOQA: T201 RUF100 + tmuxp_echo( colors.muted("Opening ") + colors.info(str(PrivatePath(workspace_file))) + colors.muted(" in ") diff --git a/src/tmuxp/cli/freeze.py b/src/tmuxp/cli/freeze.py index 9b48ebf01e..fa26569ca7 100644 --- a/src/tmuxp/cli/freeze.py +++ b/src/tmuxp/cli/freeze.py @@ -4,6 +4,7 @@ import argparse import locale +import logging import os import pathlib import sys @@ -19,7 +20,9 @@ from tmuxp.workspace.finders import get_workspace_dir from ._colors import Colors, build_description, get_color_mode -from .utils import prompt, prompt_choices, prompt_yes_no +from .utils import prompt, prompt_choices, prompt_yes_no, tmuxp_echo + +logger = logging.getLogger(__name__) FREEZE_DESCRIPTION = build_description( """ @@ -141,7 +144,7 @@ def command_freeze( if not session: raise exc.SessionNotFound except TmuxpException as e: - print(colors.error(str(e))) # NOQA: T201 RUF100 + tmuxp_echo(colors.error(str(e))) return frozen_workspace = freezer.freeze(session) @@ -149,7 +152,7 @@ def command_freeze( configparser = ConfigReader(workspace) if not args.quiet: - print( # NOQA: T201 RUF100 + tmuxp_echo( colors.format_separator(63) + "\n" + colors.muted("Freeze does its best to snapshot live tmux sessions.") @@ -163,7 +166,7 @@ def command_freeze( ) ): if not args.quiet: - print( # NOQA: T201 RUF100 + tmuxp_echo( colors.muted("tmuxp has examples in JSON and YAML format at ") + colors.info("") + "\n" @@ -190,7 +193,7 @@ def command_freeze( color_mode=color_mode, ) if not args.force and os.path.exists(dest_prompt): - print( # NOQA: T201 RUF100 + tmuxp_echo( colors.warning(f"{PrivatePath(dest_prompt)} exists.") + " " + colors.muted("Pick a new filename."), @@ -252,8 +255,9 @@ def extract_workspace_format( workspace, encoding=locale.getpreferredencoding(False), ) + logger.info("workspace saved", extra={"tmux_config_path": str(dest)}) if not args.quiet: - print( # NOQA: T201 RUF100 + tmuxp_echo( colors.success("Saved to ") + colors.info(str(PrivatePath(dest))) + ".", ) diff --git a/src/tmuxp/cli/import_config.py b/src/tmuxp/cli/import_config.py index 63c2d24a30..df49c221ba 100644 --- a/src/tmuxp/cli/import_config.py +++ b/src/tmuxp/cli/import_config.py @@ -3,6 +3,7 @@ from __future__ import annotations import locale +import logging import os import pathlib import sys @@ -16,6 +17,8 @@ from ._colors import ColorMode, Colors, build_description, get_color_mode from .utils import prompt, prompt_choices, prompt_yes_no, tmuxp_echo +logger = logging.getLogger(__name__) + IMPORT_DESCRIPTION = build_description( """ Import workspaces from teamocil and tmuxinator configuration files. @@ -220,6 +223,10 @@ def import_config( encoding=locale.getpreferredencoding(False), ) + logger.info( + "workspace saved", + extra={"tmux_config_path": str(dest)}, + ) tmuxp_echo( colors.success("Saved to ") + colors.info(str(PrivatePath(dest))) + ".", ) diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index fd88efcf10..b8184999eb 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -123,6 +123,7 @@ def load_plugins( module_name = ".".join(module_name[:-1]) plugin_name = plugin.split(".")[-1] except AttributeError as error: + logger.exception("plugin load failed") tmuxp_echo( colors.error("[Plugin Error]") + f" Couldn't load {plugin}\n" @@ -139,12 +140,16 @@ def load_plugins( default=True, color_mode=colors.mode, ): + logger.warning( + "plugin version constraint not met, user declined skip", + ) tmuxp_echo( colors.warning("[Not Skipping]") + " Plugin versions constraint not met. Exiting...", ) sys.exit(1) except (ImportError, AttributeError) as error: + logger.exception("plugin import failed") tmuxp_echo( colors.error("[Plugin Error]") + f" Couldn't load {plugin}\n" @@ -178,7 +183,8 @@ def _reattach(builder: WorkspaceBuilder, colors: Colors | None = None) -> None: plugin.reattach(builder.session) proc = builder.session.cmd("display-message", "-p", "'#S'") for line in proc.stdout: - print(colors.info(line) if colors else line) # NOQA: T201 RUF100 + tmuxp_echo(colors.info(line) if colors else line) + logger.debug("reattach display-message output: %s", line.strip()) if "TMUX" in os.environ: builder.session.switch_client() @@ -225,7 +231,8 @@ def _load_detached(builder: WorkspaceBuilder, colors: Colors | None = None) -> N assert builder.session is not None msg = "Session created in detached state." - print(colors.info(msg) if colors else msg) # NOQA: T201 RUF100 + tmuxp_echo(colors.info(msg) if colors else msg) + logger.info("session created in detached state") def _load_append_windows_to_current_session(builder: WorkspaceBuilder) -> None: @@ -347,10 +354,9 @@ def load_workspace( if isinstance(workspace_file, (str, os.PathLike)): workspace_file = pathlib.Path(workspace_file) - tmuxp_echo( - cli_colors.info("[Loading]") - + " " - + cli_colors.highlight(str(PrivatePath(workspace_file))), + logger.info( + "loading workspace", + extra={"tmux_config_path": str(workspace_file)}, ) # ConfigReader allows us to open a yaml or json file as a dict @@ -378,13 +384,18 @@ def load_workspace( shutil.which("tmux") # raise exception if tmux not found - try: # load WorkspaceBuilder object for tmuxp workspace / tmux server + # WorkspaceBuilder creation — outside spinner so plugin prompts are safe + try: builder = WorkspaceBuilder( session_config=expanded_workspace, plugins=load_plugins(expanded_workspace, colors=cli_colors), server=t, ) except exc.EmptyWorkspaceException: + logger.warning( + "workspace file is empty", + extra={"tmux_config_path": str(workspace_file)}, + ) tmuxp_echo( cli_colors.warning("[Warning]") + f" {PrivatePath(workspace_file)} is empty or parsed no workspace data", @@ -393,7 +404,7 @@ def load_workspace( session_name = expanded_workspace["session_name"] - # if the session already exists, prompt the user to attach + # Session-exists check — outside spinner so prompt_yes_no is safe if builder.session_exists(session_name) and not append: if not detached and ( answer_yes @@ -442,9 +453,7 @@ def load_workspace( _load_attached(builder, detached) except exc.TmuxpException as e: - import traceback - - tmuxp_echo(traceback.format_exc()) + logger.exception("workspace build failed") tmuxp_echo(cli_colors.error("[Error]") + f" {e}") choice = prompt_choices( @@ -459,6 +468,7 @@ def load_workspace( if builder.session is not None: builder.session.kill() tmuxp_echo(cli_colors.muted("Session killed.")) + logger.info("session killed by user after build error") elif choice == "a": _reattach(builder, cli_colors) else: diff --git a/src/tmuxp/cli/shell.py b/src/tmuxp/cli/shell.py index e62f0a0758..57a5ae8e4b 100644 --- a/src/tmuxp/cli/shell.py +++ b/src/tmuxp/cli/shell.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import logging import os import pathlib import typing as t @@ -13,6 +14,9 @@ from tmuxp._compat import PY3, PYMINOR from ._colors import Colors, build_description, get_color_mode +from .utils import tmuxp_echo + +logger = logging.getLogger(__name__) SHELL_DESCRIPTION = build_description( """ @@ -222,7 +226,7 @@ def command_shell( ): from tmuxp._compat import breakpoint as tmuxp_breakpoint - print( # NOQA: T201 RUF100 + tmuxp_echo( cli_colors.muted("Launching ") + cli_colors.highlight("pdb", bold=False) + cli_colors.muted(" shell..."), @@ -233,7 +237,7 @@ def command_shell( from tmuxp.shell import launch shell_name = args.shell or "best" - print( # NOQA: T201 RUF100 + tmuxp_echo( cli_colors.muted("Launching ") + cli_colors.highlight(shell_name, bold=False) + cli_colors.muted(" shell for session ") diff --git a/src/tmuxp/cli/utils.py b/src/tmuxp/cli/utils.py index 58896deb0f..034c98b2ed 100644 --- a/src/tmuxp/cli/utils.py +++ b/src/tmuxp/cli/utils.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import typing as t from tmuxp._internal.colors import ( @@ -15,6 +16,8 @@ from tmuxp._internal.private_path import PrivatePath from tmuxp.log import tmuxp_echo +logger = logging.getLogger(__name__) + if t.TYPE_CHECKING: from collections.abc import Callable, Sequence @@ -215,7 +218,7 @@ def prompt_choices( return None if rv in choices_: return rv - print( + tmuxp_echo( colors.warning(f"Invalid choice '{rv}'. ") + f"Please choose from: {', '.join(choices_)}" ) diff --git a/src/tmuxp/plugin.py b/src/tmuxp/plugin.py index 84be58d96d..fa153c7771 100644 --- a/src/tmuxp/plugin.py +++ b/src/tmuxp/plugin.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import typing as t import libtmux @@ -11,6 +12,8 @@ from .__about__ import __version__ from .exc import TmuxpPluginException +logger = logging.getLogger(__name__) + #: Minimum version of tmux required to run tmuxp TMUX_MIN_VERSION = "3.2" @@ -181,6 +184,7 @@ def __init__(self, **kwargs: Unpack[PluginConfigSchema]) -> None: def _version_check(self) -> None: """Check all dependency versions for compatibility.""" + logger.debug("checking version constraints for %s", self.plugin_name) for dep, constraints in self.version_constraints.items(): assert isinstance(constraints, dict) try: diff --git a/src/tmuxp/shell.py b/src/tmuxp/shell.py index aea0e92020..3d56655ba5 100644 --- a/src/tmuxp/shell.py +++ b/src/tmuxp/shell.py @@ -106,14 +106,17 @@ def has_bpython() -> bool: def detect_best_shell() -> CLIShellLiteral: """Return the best, most feature-rich shell available.""" if has_ptipython(): - return "ptipython" - if has_ptpython(): - return "ptpython" - if has_ipython(): - return "ipython" - if has_bpython(): - return "bpython" - return "code" + shell: CLIShellLiteral = "ptipython" + elif has_ptpython(): + shell = "ptpython" + elif has_ipython(): + shell = "ipython" + elif has_bpython(): + shell = "bpython" + else: + shell = "code" + logger.debug("detected shell: %s", shell) + return shell def get_bpython( diff --git a/src/tmuxp/util.py b/src/tmuxp/util.py index 490ee7e940..f069a47aec 100644 --- a/src/tmuxp/util.py +++ b/src/tmuxp/util.py @@ -10,6 +10,7 @@ import typing as t from . import exc +from .log import tmuxp_echo if t.TYPE_CHECKING: import pathlib @@ -110,7 +111,9 @@ def oh_my_zsh_auto_title() -> None: or os.environ.get("DISABLE_AUTO_TITLE") == "false" ) ): - print( # NOQA: T201 RUF100 + logger.warning("oh-my-zsh DISABLE_AUTO_TITLE not set") + tmuxp_echo( + "oh-my-zsh DISABLE_AUTO_TITLE not set.\n\n" "Please set:\n\n" "\texport DISABLE_AUTO_TITLE='true'\n\n" "in ~/.zshrc or where your zsh profile is stored.\n" @@ -189,8 +192,15 @@ def get_pane(window: Window, current_pane: Pane | None = None) -> Pane: pane = window.panes.get(pane_id=current_pane.pane_id) else: pane = window.active_pane - except exc.TmuxpException as e: - print(e) # NOQA: T201 RUF100 + except Exception as e: + logger.debug( + "pane lookup failed", + exc_info=True, + extra={"tmux_pane": str(current_pane) if current_pane else ""}, + ) + if current_pane: + raise exc.PaneNotFound(str(current_pane)) from e + raise exc.PaneNotFound from e if pane is None: if current_pane: diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 24c93b3c24..18154b997a 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -15,6 +15,7 @@ from libtmux.window import Window from tmuxp import exc +from tmuxp.log import TmuxpLoggerAdapter from tmuxp.util import get_current_pane, run_before_script if t.TYPE_CHECKING: @@ -332,6 +333,11 @@ def build(self, session: Session | None = None, append: bool = False) -> None: assert session.name is not None self._session = session + _log = TmuxpLoggerAdapter( + logger, + {"tmux_session": self.session_config["session_name"]}, + ) + _log.info("session created") assert session.server is not None @@ -355,8 +361,12 @@ def build(self, session: Session | None = None, append: bool = False) -> None: # session start directory, if it exists. if "start_directory" in self.session_config: cwd = self.session_config["start_directory"] + _log.debug( + "running before script", + ) run_before_script(self.session_config["before_script"], cwd=cwd) except Exception: + _log.error("before script failed") self.session.kill() raise @@ -400,6 +410,8 @@ def build(self, session: Session | None = None, append: bool = False) -> None: if focus: focus.select() + _log.info("workspace built") + def iter_create_windows( self, session: Session, @@ -469,6 +481,14 @@ def iter_create_windows( environment=environment, ) assert isinstance(window, Window) + window_log = TmuxpLoggerAdapter( + logger, + { + "tmux_session": session.name or "", + "tmux_window": window_name or "", + }, + ) + window_log.debug("window created") if is_first_window_pass: # if first window, use window 1 session.active_window.kill() @@ -563,6 +583,15 @@ def get_pane_shell( ) assert isinstance(pane, Pane) + pane_log = TmuxpLoggerAdapter( + logger, + { + "tmux_session": window.session.name or "", + "tmux_window": window.name or "", + "tmux_pane": pane.pane_id or "", + }, + ) + pane_log.debug("pane created") # Skip readiness wait when a custom shell/command launcher is set. # The shell/window_shell key runs a command (e.g. "top", "sleep 999") @@ -594,6 +623,7 @@ def get_pane_shell( time.sleep(sleep_before) pane.send_keys(cmd["cmd"], suppress_history=suppress, enter=enter) + pane_log.debug("sent command %s", cmd["cmd"]) if sleep_after is not None: time.sleep(sleep_after) diff --git a/src/tmuxp/workspace/finders.py b/src/tmuxp/workspace/finders.py index da19bcc887..2bc7704c28 100644 --- a/src/tmuxp/workspace/finders.py +++ b/src/tmuxp/workspace/finders.py @@ -142,6 +142,12 @@ def find_local_workspace_files( if start_dir is None: start_dir = os.getcwd() + logger.debug( + "searching for local workspace files from %s", + start_dir, + extra={"tmux_config_path": str(start_dir)}, + ) + current = pathlib.Path(start_dir).resolve() home = pathlib.Path.home().resolve() found: list[pathlib.Path] = [] @@ -361,12 +367,17 @@ def find_workspace_file( ] if len(candidates) > 1: + logger.warning( + "multiple workspace files found, use distinct file names" + " to avoid ambiguity", + extra={"tmux_config_path": workspace_file}, + ) colors = Colors(ColorMode.AUTO) tmuxp_echo( colors.error( - "Multiple .tmuxp.{yml,yaml,json} workspace_files in " - + dirname(workspace_file) - ), + "Multiple .tmuxp.{yaml,yml,json} files found in " + + str(workspace_file) + ) ) tmuxp_echo( "This is undefined behavior, use only one. " @@ -383,6 +394,11 @@ def find_workspace_file( if file_error: raise FileNotFoundError(file_error, workspace_file) + logger.debug( + "resolved workspace file %s", + workspace_file, + extra={"tmux_config_path": workspace_file}, + ) return workspace_file diff --git a/src/tmuxp/workspace/freezer.py b/src/tmuxp/workspace/freezer.py index 8807e9e43e..7ec302494f 100644 --- a/src/tmuxp/workspace/freezer.py +++ b/src/tmuxp/workspace/freezer.py @@ -2,8 +2,11 @@ from __future__ import annotations +import logging import typing as t +logger = logging.getLogger(__name__) + if t.TYPE_CHECKING: from libtmux.pane import Pane from libtmux.session import Session @@ -64,6 +67,8 @@ def freeze(session: Session) -> dict[str, t.Any]: dict tmuxp compatible workspace """ + logger.debug("freezing session", extra={"tmux_session": session.session_name}) + session_config: dict[str, t.Any] = { "session_name": session.session_name, "windows": [], @@ -119,5 +124,12 @@ def filter_interpreters_and_shells(current_cmd: str | None) -> bool: window_config["panes"].append(pane_config) session_config["windows"].append(window_config) + logger.debug( + "frozen window", + extra={ + "tmux_session": session.session_name, + "tmux_window": window.name, + }, + ) return session_config diff --git a/src/tmuxp/workspace/importers.py b/src/tmuxp/workspace/importers.py index fda361ca6f..65184d73a4 100644 --- a/src/tmuxp/workspace/importers.py +++ b/src/tmuxp/workspace/importers.py @@ -2,8 +2,11 @@ from __future__ import annotations +import logging import typing as t +logger = logging.getLogger(__name__) + def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: """Return tmuxp workspace from a `tmuxinator`_ yaml workspace. @@ -19,6 +22,14 @@ def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: ------- dict """ + logger.debug( + "importing tmuxinator workspace", + extra={ + "tmux_session": workspace_dict.get("project_name") + or workspace_dict.get("name", ""), + }, + ) + tmuxp_workspace: dict[str, t.Any] = {} if "project_name" in workspace_dict: @@ -122,6 +133,12 @@ def import_teamocil(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: - clear - cmd_separator """ + _inner = workspace_dict.get("session", workspace_dict) + logger.debug( + "importing teamocil workspace", + extra={"tmux_session": _inner.get("name", "")}, + ) + tmuxp_workspace: dict[str, t.Any] = {} if "session" in workspace_dict: diff --git a/src/tmuxp/workspace/loader.py b/src/tmuxp/workspace/loader.py index 613b2b589d..9efcd05b52 100644 --- a/src/tmuxp/workspace/loader.py +++ b/src/tmuxp/workspace/loader.py @@ -101,6 +101,11 @@ def expand( ------- dict """ + logger.debug( + "expanding workspace config", + extra={"tmux_session": workspace_dict.get("session_name", "")}, + ) + # Note: cli.py will expand workspaces relative to project's workspace directory # for the first cwd argument. cwd = pathlib.Path().cwd() if not cwd else pathlib.Path(cwd) @@ -207,6 +212,11 @@ def trickle(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: ------- dict """ + logger.debug( + "trickling down workspace defaults", + extra={"tmux_session": workspace_dict.get("session_name", "")}, + ) + # prepends a pane's ``shell_command`` list with the window and sessions' # ``shell_command_before``. diff --git a/src/tmuxp/workspace/validation.py b/src/tmuxp/workspace/validation.py index 2573209a5d..e7fe4da743 100644 --- a/src/tmuxp/workspace/validation.py +++ b/src/tmuxp/workspace/validation.py @@ -2,10 +2,13 @@ from __future__ import annotations +import logging import typing as t from tmuxp import exc +logger = logging.getLogger(__name__) + class SchemaValidationError(exc.WorkspaceError): """Tmuxp configuration validation base error.""" @@ -70,6 +73,15 @@ def validate_schema(workspace_dict: t.Any) -> bool: ------- bool """ + logger.debug( + "validating workspace schema", + extra={ + "tmux_session": workspace_dict.get("session_name", "") + if isinstance(workspace_dict, dict) + else "", + }, + ) + # verify session_name if "session_name" not in workspace_dict: raise SessionNameMissingValidationError From aaaffe5f7e4f8b5c4976ed39d8e9820a17f29521 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 14:48:32 -0500 Subject: [PATCH 06/50] test(logging[caplog]): add structured log assertions across all modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Verify structured extra keys on log records using caplog.records, per AGENTS.md guidelines — assert on attributes, not string matching. what: - Add caplog tests for builder, freezer, finders, loader, validation, importers, and plugin version_check - Add log-level filtering test for --log-file - Add ANSI-free assertions to JSON/NDJSON output tests (ls, search) - Add non-TTY stderr ANSI-free test for load command --- tests/cli/test_load.py | 74 +++++++++++++++---- tests/cli/test_ls.py | 2 + tests/cli/test_search.py | 2 + tests/test_plugin.py | 14 ++++ tests/test_util.py | 70 +++++++++++++++++- tests/workspace/test_builder.py | 89 ++++++++++++++++++++++- tests/workspace/test_config.py | 47 ++++++++++++ tests/workspace/test_finder.py | 52 +++++++++++++ tests/workspace/test_freezer.py | 28 +++++++ tests/workspace/test_import_teamocil.py | 18 +++++ tests/workspace/test_import_tmuxinator.py | 16 ++++ 11 files changed, 395 insertions(+), 17 deletions(-) diff --git a/tests/cli/test_load.py b/tests/cli/test_load.py index 2191b7320c..9ecffd2471 100644 --- a/tests/cli/test_load.py +++ b/tests/cli/test_load.py @@ -17,7 +17,6 @@ from tmuxp import cli from tmuxp._internal.config_reader import ConfigReader from tmuxp._internal.private_path import PrivatePath -from tmuxp.cli._colors import ColorMode, Colors from tmuxp.cli.load import ( _load_append_windows_to_current_session, _load_attached, @@ -446,7 +445,7 @@ class LogFileTestFixture(t.NamedTuple): LOG_FILE_TEST_FIXTURES: list[LogFileTestFixture] = [ LogFileTestFixture( test_id="load_with_log_file", - cli_args=["load", ".", "--log-file", "log.txt", "-d"], + cli_args=["--log-level", "info", "load", ".", "--log-file", "log.txt", "-d"], ), ] @@ -484,10 +483,45 @@ def test_load_log_file( result = capsys.readouterr() log_file_path = tmp_path / "log.txt" - assert "Loading" in log_file_path.open().read() + assert "loading workspace" in log_file_path.open().read() assert result.out is not None +def test_load_log_file_level_filtering( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Log-level filtering: INFO log file should not contain DEBUG messages.""" + tmuxp_config_path = tmp_path / ".tmuxp.yaml" + tmuxp_config_path.write_text( + """ +session_name: hello + - + """, + encoding="utf-8", + ) + oh_my_zsh_path = tmp_path / ".oh-my-zsh" + oh_my_zsh_path.mkdir() + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.chdir(tmp_path) + + with contextlib.suppress(Exception): + cli.cli(["--log-level", "info", "load", ".", "--log-file", "log.txt", "-d"]) + + log_file_path = tmp_path / "log.txt" + log_contents = log_file_path.read_text() + + # INFO-level messages should appear + assert "loading workspace" in log_contents.lower() or len(log_contents) > 0 + + # No DEBUG-level markers should appear in an INFO-level log file + for line in log_contents.splitlines(): + assert "(DEBUG)" not in line, ( + f"DEBUG message leaked into INFO-level log file: {line}" + ) + + def test_load_plugins( monkeypatch_plugin_test_packages: None, ) -> None: @@ -548,7 +582,7 @@ def test_load_plugins_version_fail_skip( result = capsys.readouterr() - assert "[Loading]" in result.out + assert "Loading" in result.out or "Loaded" in result.out PLUGIN_VERSION_NO_SKIP_TEST_FIXTURES: list[PluginVersionTestFixture] = [ @@ -758,18 +792,28 @@ def test_load_append_windows_to_current_session( # Privacy masking in load command -def test_load_masks_home_in_loading_message(monkeypatch: pytest.MonkeyPatch) -> None: - """Load command should mask home directory in [Loading] message.""" +def test_load_no_ansi_in_nontty_stderr( + server: Server, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """No ANSI escape codes in stderr when running in non-TTY context (CI/pipe).""" + monkeypatch.delenv("TMUX", raising=False) + session_file = FIXTURE_PATH / "workspace/builder" / "two_pane.yaml" + + load_workspace(str(session_file), socket_name=server.socket_name, detached=True) + + captured = capsys.readouterr() + assert "\x1b[" not in captured.err, "ANSI codes leaked into non-TTY stderr" + + +def test_load_masks_home_in_spinner_message(monkeypatch: pytest.MonkeyPatch) -> None: + """Spinner message should mask home directory via PrivatePath.""" monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) workspace_file = pathlib.Path("/home/testuser/work/project/.tmuxp.yaml") - output = ( - colors.info("[Loading]") - + " " - + colors.highlight(str(PrivatePath(workspace_file))) - ) + private_path = str(PrivatePath(workspace_file)) + message = f"Loading workspace: myproject ({private_path})" - assert "~/work/project/.tmuxp.yaml" in output - assert "/home/testuser" not in output + assert "~/work/project/.tmuxp.yaml" in message + assert "/home/testuser" not in message diff --git a/tests/cli/test_ls.py b/tests/cli/test_ls.py index 40e1526839..e6f64e28fc 100644 --- a/tests/cli/test_ls.py +++ b/tests/cli/test_ls.py @@ -161,6 +161,7 @@ def test_ls_json_output( cli.cli(["ls", "--json"]) output = capsys.readouterr().out + assert "\x1b" not in output, "ANSI escapes must not leak into machine output" data = json.loads(output) # JSON output is now an object with workspaces and global_workspace_dirs @@ -200,6 +201,7 @@ def test_ls_ndjson_output( cli.cli(["ls", "--ndjson"]) output = capsys.readouterr().out + assert "\x1b" not in output, "ANSI escapes must not leak into machine output" lines = [line for line in output.strip().split("\n") if line] assert len(lines) == 2 diff --git a/tests/cli/test_search.py b/tests/cli/test_search.py index e4140b2645..9e67266b86 100644 --- a/tests/cli/test_search.py +++ b/tests/cli/test_search.py @@ -830,6 +830,7 @@ def test_output_search_results_json(capsys: pytest.CaptureFixture[str]) -> None: formatter.finalize() captured = capsys.readouterr() + assert "\x1b" not in captured.out, "ANSI escapes must not leak into machine output" data = json.loads(captured.out) assert len(data) == 1 assert data[0]["name"] == "dev" @@ -857,6 +858,7 @@ def test_output_search_results_ndjson(capsys: pytest.CaptureFixture[str]) -> Non formatter.finalize() captured = capsys.readouterr() + assert "\x1b" not in captured.out, "ANSI escapes must not leak into machine output" lines = captured.out.strip().split("\n") # Filter out human-readable lines json_lines = [line for line in lines if line.startswith("{")] diff --git a/tests/test_plugin.py b/tests/test_plugin.py index cf7cfc6371..6d7fda7fd1 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -2,6 +2,8 @@ from __future__ import annotations +import logging + import pytest from tmuxp.exc import TmuxpPluginException @@ -95,3 +97,15 @@ def test_libtmux_version_fail_incompatible() -> None: with pytest.raises(TmuxpPluginException, match=r"Incompatible.*") as exc_info: LibtmuxVersionFailIncompatiblePlugin() assert "libtmux-incompatible-version-fail" in str(exc_info.value) + + +def test_plugin_version_check_logs_debug( + caplog: pytest.LogCaptureFixture, +) -> None: + """_version_check() logs DEBUG with plugin name.""" + with caplog.at_level(logging.DEBUG, logger="tmuxp.plugin"): + AllVersionPassPlugin() + records = [ + r for r in caplog.records if r.msg == "checking version constraints for %s" + ] + assert len(records) >= 1 diff --git a/tests/test_util.py b/tests/test_util.py index baa592e9a8..098c8c212b 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -2,6 +2,9 @@ from __future__ import annotations +import logging +import os +import pathlib import sys import typing as t @@ -9,7 +12,7 @@ from tmuxp import exc from tmuxp.exc import BeforeLoadScriptError, BeforeLoadScriptNotExists -from tmuxp.util import get_session, run_before_script +from tmuxp.util import get_pane, get_session, oh_my_zsh_auto_title, run_before_script from .constants import FIXTURE_PATH @@ -166,3 +169,68 @@ def test_get_session_should_return_first_session_if_no_active_session( server.new_session(session_name="mysecondsession") assert get_session(server) == first_session + + +def test_get_pane_logs_debug_on_failure( + server: Server, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """get_pane() logs DEBUG with tmux_pane extra when pane lookup fails.""" + session = server.new_session(session_name="test_pane_log") + window = session.active_window + + # Make active_pane raise Exception to trigger the logging path + monkeypatch.setattr( + type(window), + "active_pane", + property(lambda self: (_ for _ in ()).throw(Exception("mock pane error"))), + ) + + with ( + caplog.at_level(logging.DEBUG, logger="tmuxp.util"), + pytest.raises(exc.PaneNotFound), + ): + get_pane(window, current_pane=None) + + debug_records = [ + r + for r in caplog.records + if hasattr(r, "tmux_pane") and r.levelno == logging.DEBUG + ] + assert len(debug_records) >= 1 + assert debug_records[0].tmux_pane == "" + + +def test_oh_my_zsh_auto_title_logs_warning( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + tmp_path: t.Any, +) -> None: + """oh_my_zsh_auto_title() logs WARNING when DISABLE_AUTO_TITLE not set.""" + monkeypatch.setenv("SHELL", "/bin/zsh") + monkeypatch.delenv("DISABLE_AUTO_TITLE", raising=False) + + # Create fake ~/.oh-my-zsh directory + fake_home = tmp_path / "home" + fake_home.mkdir() + oh_my_zsh_dir = fake_home / ".oh-my-zsh" + oh_my_zsh_dir.mkdir() + monkeypatch.setenv("HOME", str(fake_home)) + + # Patch os.path.exists to return True for ~/.oh-my-zsh + original_exists = os.path.exists + + def patched_exists(path: str) -> bool: + if path == str(pathlib.Path("~/.oh-my-zsh").expanduser()): + return True + return original_exists(path) + + monkeypatch.setattr(os.path, "exists", patched_exists) + + with caplog.at_level(logging.WARNING, logger="tmuxp.util"): + oh_my_zsh_auto_title() + + warning_records = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warning_records) >= 1 + assert "DISABLE_AUTO_TITLE" in warning_records[0].message diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index 6b78dfcbd3..da95168f46 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -3,6 +3,7 @@ from __future__ import annotations import functools +import logging import os import pathlib import textwrap @@ -697,6 +698,7 @@ def test_window_index( def test_before_script_throw_error_if_retcode_error( server: Server, + caplog: pytest.LogCaptureFixture, ) -> None: """Test tmuxp configuration before_script when command fails.""" config_script_fails = test_utils.read_workspace_file( @@ -716,12 +718,20 @@ def test_before_script_throw_error_if_retcode_error( session_name = sess.name assert session_name is not None - with pytest.raises(exc.BeforeLoadScriptError): + with ( + caplog.at_level(logging.ERROR, logger="tmuxp.workspace.builder"), + pytest.raises(exc.BeforeLoadScriptError), + ): builder.build(session=sess) result = server.has_session(session_name) assert not result, "Kills session if before_script exits with errcode" + error_records = [r for r in caplog.records if r.levelno == logging.ERROR] + assert len(error_records) >= 1 + assert error_records[0].msg == "before script failed" + assert hasattr(error_records[0], "tmux_session") + def test_before_script_throw_error_if_file_not_exists( server: Server, @@ -1681,3 +1691,80 @@ def counting_layout(self: Window, layout: str | None = None) -> Window: builder.build() # 3 panes = 3 layout calls (one per pane in iter_create_panes), not 6 assert call_count == 3 + + +def test_builder_logs_session_created( + server: Server, + caplog: pytest.LogCaptureFixture, +) -> None: + """WorkspaceBuilder.build() logs INFO with tmux_session extra.""" + workspace = { + "session_name": "test_log_session", + "windows": [ + { + "window_name": "main", + "panes": [ + {"shell_command": []}, + ], + }, + ], + } + builder = WorkspaceBuilder(session_config=workspace, server=server) + + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.builder"): + builder.build() + + session_logs = [ + r + for r in caplog.records + if hasattr(r, "tmux_session") and r.msg == "session created" + ] + assert len(session_logs) >= 1 + assert session_logs[0].tmux_session == "test_log_session" + + # Verify workspace built log + built_logs = [r for r in caplog.records if r.msg == "workspace built"] + assert len(built_logs) >= 1 + + builder.session.kill() + + +def test_builder_logs_window_and_pane_creation( + server: Server, + caplog: pytest.LogCaptureFixture, +) -> None: + """WorkspaceBuilder logs DEBUG with tmux_window and tmux_pane extra.""" + workspace = { + "session_name": "test_log_wp", + "windows": [ + { + "window_name": "editor", + "panes": [ + {"shell_command": [{"cmd": "echo hello"}]}, + {"shell_command": []}, + ], + }, + ], + } + builder = WorkspaceBuilder(session_config=workspace, server=server) + + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.builder"): + builder.build() + + window_logs = [ + r + for r in caplog.records + if hasattr(r, "tmux_window") and r.msg == "window created" + ] + assert len(window_logs) >= 1 + assert window_logs[0].tmux_window == "editor" + + pane_logs = [ + r for r in caplog.records if hasattr(r, "tmux_pane") and r.msg == "pane created" + ] + assert len(pane_logs) >= 1 + + cmd_logs = [r for r in caplog.records if r.msg == "sent command %s"] + assert len(cmd_logs) >= 1 + + builder.session.kill() diff --git a/tests/workspace/test_config.py b/tests/workspace/test_config.py index 02ebcf5ffa..fc6d5ccd5b 100644 --- a/tests/workspace/test_config.py +++ b/tests/workspace/test_config.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import pathlib import typing as t @@ -330,3 +331,49 @@ def test_validate_plugins() -> None: with pytest.raises(exc.WorkspaceError) as excinfo: validation.validate_schema(sconfig) assert excinfo.match("only supports list type") + + +def test_expand_logs_debug( + tmp_path: pathlib.Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """expand() logs DEBUG with tmux_session extra.""" + workspace = {"session_name": "test_expand", "windows": [{"window_name": "main"}]} + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.loader"): + loader.expand(workspace, cwd=str(tmp_path)) + records = [r for r in caplog.records if r.msg == "expanding workspace config"] + assert len(records) >= 1 + assert getattr(records[0], "tmux_session", None) == "test_expand" + + +def test_trickle_logs_debug( + tmp_path: pathlib.Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """trickle() logs DEBUG with tmux_session extra.""" + workspace = { + "session_name": "test_trickle", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.loader"): + loader.trickle(workspace) + records = [ + r for r in caplog.records if r.msg == "trickling down workspace defaults" + ] + assert len(records) >= 1 + assert getattr(records[0], "tmux_session", None) == "test_trickle" + + +def test_validate_schema_logs_debug( + caplog: pytest.LogCaptureFixture, +) -> None: + """validate_schema() logs DEBUG with tmux_session extra.""" + workspace = { + "session_name": "test_validate", + "windows": [{"window_name": "main"}], + } + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.validation"): + validation.validate_schema(workspace) + records = [r for r in caplog.records if r.msg == "validating workspace schema"] + assert len(records) >= 1 + assert getattr(records[0], "tmux_session", None) == "test_validate" diff --git a/tests/workspace/test_finder.py b/tests/workspace/test_finder.py index dd0b270bb8..ab9a69dba4 100644 --- a/tests/workspace/test_finder.py +++ b/tests/workspace/test_finder.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import logging import pathlib import typing as t @@ -11,6 +12,7 @@ from tmuxp import cli from tmuxp.cli.utils import tmuxp_echo from tmuxp.workspace.finders import ( + find_local_workspace_files, find_workspace_file, get_workspace_dir, get_workspace_dir_candidates, @@ -514,3 +516,53 @@ def test_get_workspace_dir_candidates_uses_private_path( path = candidate["path"] assert str(home) not in path, f"Path should be masked: {path}" assert path.startswith("~"), f"Path should start with ~: {path}" + + +def test_find_workspace_file_logs_warning_on_multiple( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + capsys: pytest.CaptureFixture[str], +) -> None: + """find_workspace_file() logs WARNING when multiple workspace files found.""" + project = tmp_path / "project" + project.mkdir() + + # Create multiple .tmuxp files in the same directory + (project / ".tmuxp.yaml").write_text("session_name: test") + (project / ".tmuxp.json").write_text('{"session_name": "test"}') + + monkeypatch.chdir(project) + + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.finders"): + find_workspace_file(str(project)) + + warning_records = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warning_records) >= 1 + assert "multiple workspace files found" in warning_records[0].message + assert hasattr(warning_records[0], "tmux_config_path") + + out = capsys.readouterr().out + assert "Multiple .tmuxp." in out + assert "undefined behavior" in out + + +def test_find_local_workspace_files_logs_debug( + tmp_path: pathlib.Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """find_local_workspace_files() logs DEBUG with tmux_config_path extra.""" + project = tmp_path / "project" + project.mkdir() + (project / ".tmuxp.yaml").write_text("session_name: test") + + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.finders"): + find_local_workspace_files(project, stop_at_home=False) + + records = [ + r + for r in caplog.records + if r.msg == "searching for local workspace files from %s" + ] + assert len(records) >= 1 + assert hasattr(records[0], "tmux_config_path") diff --git a/tests/workspace/test_freezer.py b/tests/workspace/test_freezer.py index 42fa6cc581..d42386ecef 100644 --- a/tests/workspace/test_freezer.py +++ b/tests/workspace/test_freezer.py @@ -2,9 +2,12 @@ from __future__ import annotations +import logging import time import typing +import pytest + from tests.fixtures import utils as test_utils from tmuxp._internal.config_reader import ConfigReader from tmuxp.workspace import freezer, validation @@ -106,3 +109,28 @@ def test_export_yaml( new_workspace_data = ConfigReader._from_file(yaml_workspace_file) assert config_fixture.sample_workspace.sample_workspace_dict == new_workspace_data + + +def test_freeze_logs_debug( + session: Session, + caplog: pytest.LogCaptureFixture, +) -> None: + """freeze() logs DEBUG with tmux_session extra.""" + session_config = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/freezer/sample_workspace.yaml"), + ) + builder = WorkspaceBuilder(session_config=session_config, server=session.server) + builder.build(session=session) + + time.sleep(0.50) + + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.freezer"): + freezer.freeze(session) + + freeze_records = [r for r in caplog.records if r.msg == "freezing session"] + assert len(freeze_records) >= 1 + assert hasattr(freeze_records[0], "tmux_session") + + window_records = [r for r in caplog.records if r.msg == "frozen window"] + assert len(window_records) >= 1 + assert hasattr(window_records[0], "tmux_window") diff --git a/tests/workspace/test_import_teamocil.py b/tests/workspace/test_import_teamocil.py index 7de727684b..0ea457e7c6 100644 --- a/tests/workspace/test_import_teamocil.py +++ b/tests/workspace/test_import_teamocil.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import typing as t import pytest @@ -139,3 +140,20 @@ def test_multisession_config( validation.validate_schema( importers.import_teamocil(multisession_config[session_name]), ) + + +def test_import_teamocil_logs_debug( + caplog: pytest.LogCaptureFixture, +) -> None: + """import_teamocil() logs DEBUG record.""" + workspace = { + "session": { + "name": "test", + "windows": [{"name": "main", "panes": [{"cmd": "echo hi"}]}], + }, + } + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.importers"): + importers.import_teamocil(workspace) + records = [r for r in caplog.records if r.msg == "importing teamocil workspace"] + assert len(records) >= 1 + assert getattr(records[0], "tmux_session", None) == "test" diff --git a/tests/workspace/test_import_tmuxinator.py b/tests/workspace/test_import_tmuxinator.py index 23f567ae5d..457605f2ab 100644 --- a/tests/workspace/test_import_tmuxinator.py +++ b/tests/workspace/test_import_tmuxinator.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import typing as t import pytest @@ -60,3 +61,18 @@ def test_config_to_dict( assert importers.import_tmuxinator(tmuxinator_dict) == tmuxp_dict validation.validate_schema(importers.import_tmuxinator(tmuxinator_dict)) + + +def test_import_tmuxinator_logs_debug( + caplog: pytest.LogCaptureFixture, +) -> None: + """import_tmuxinator() logs DEBUG record.""" + workspace = { + "name": "test", + "windows": [{"main": ["echo hi"]}], + } + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.importers"): + importers.import_tmuxinator(workspace) + records = [r for r in caplog.records if r.msg == "importing tmuxinator workspace"] + assert len(records) >= 1 + assert getattr(records[0], "tmux_session", None) == "test" From 3d7b95138b3879d12912a8a56101f15b660c6839 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 14:48:45 -0500 Subject: [PATCH 07/50] refactor(deps[colorama]): replace colorama with stdlib ANSI constants why: colorama wraps fixed ANSI escape string constants the stdlib can provide directly. Removing it shrinks the dependency tree. what: - Replace all colorama Fore/Style references in log.py with raw ANSI escapes via _ansi_colors from tmuxp._internal.colors - Remove colorama and types-colorama from pyproject.toml --- pyproject.toml | 3 --- src/tmuxp/log.py | 67 +++++++++++++++++++++++++----------------------- uv.lock | 15 ----------- 3 files changed, 35 insertions(+), 50 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4e908892de..aa884dea6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,6 @@ include = [ ] dependencies = [ "libtmux~=0.55.0", - "colorama>=0.3.9", "PyYAML>=6.0" ] @@ -82,7 +81,6 @@ dev = [ # Lint "ruff", "mypy", - "types-colorama", "types-docutils", "types-Pygments", "types-PyYAML", @@ -118,7 +116,6 @@ coverage =[ lint = [ "ruff", "mypy", - "types-colorama", "types-docutils", "types-Pygments", "types-PyYAML", diff --git a/src/tmuxp/log.py b/src/tmuxp/log.py index 15367d5310..cec5da5ac4 100644 --- a/src/tmuxp/log.py +++ b/src/tmuxp/log.py @@ -8,16 +8,20 @@ import time import typing as t -from colorama import Fore, Style +from tmuxp._internal.colors import _ansi_colors, _ansi_reset_all logger = logging.getLogger(__name__) +_ANSI_RESET = _ansi_reset_all # "\033[0m" +_ANSI_BRIGHT = "\033[1m" +_ANSI_FG_RESET = "\033[39m" + LEVEL_COLORS = { - "DEBUG": Fore.BLUE, # Blue - "INFO": Fore.GREEN, # Green - "WARNING": Fore.YELLOW, - "ERROR": Fore.RED, - "CRITICAL": Fore.RED, + "DEBUG": f"\033[{_ansi_colors['blue']}m", + "INFO": f"\033[{_ansi_colors['green']}m", + "WARNING": f"\033[{_ansi_colors['yellow']}m", + "ERROR": f"\033[{_ansi_colors['red']}m", + "CRITICAL": f"\033[{_ansi_colors['red']}m", } @@ -103,27 +107,27 @@ def template( str Template for logger message. """ - reset = Style.RESET_ALL + reset = _ANSI_RESET levelname = set_style( "(%(levelname)s)", stylized, - style_before=(LEVEL_COLORS.get(record.levelname, "") + Style.BRIGHT), - style_after=Style.RESET_ALL, + style_before=(LEVEL_COLORS.get(record.levelname, "") + _ANSI_BRIGHT), + style_after=_ANSI_RESET, suffix=" ", ) asctime = set_style( "%(asctime)s", stylized, - style_before=(Fore.BLACK + Style.DIM + Style.BRIGHT), - style_after=(Fore.RESET + Style.RESET_ALL), + style_before=(f"\033[{_ansi_colors['black']}m" + _ANSI_BRIGHT), + style_after=(_ANSI_FG_RESET + _ANSI_RESET), prefix="[", suffix="]", ) name = set_style( "%(name)s", stylized, - style_before=(Fore.WHITE + Style.DIM + Style.BRIGHT), - style_after=(Fore.RESET + Style.RESET_ALL), + style_before=(f"\033[{_ansi_colors['white']}m" + _ANSI_BRIGHT), + style_after=(_ANSI_FG_RESET + _ANSI_RESET), prefix=" ", suffix=" ", ) @@ -173,42 +177,41 @@ def debug_log_template( str Log template. """ - reset = Style.RESET_ALL + reset = _ANSI_RESET levelname = ( LEVEL_COLORS.get(record.levelname, "") - + Style.BRIGHT + + _ANSI_BRIGHT + "(%(levelname)1.1s)" - + Style.RESET_ALL + + _ANSI_RESET + " " ) asctime = ( "[" - + Fore.BLACK - + Style.DIM - + Style.BRIGHT + + f"\033[{_ansi_colors['black']}m" + + _ANSI_BRIGHT + "%(asctime)s" - + Fore.RESET - + Style.RESET_ALL + + _ANSI_FG_RESET + + _ANSI_RESET + "]" ) name = ( " " - + Fore.WHITE - + Style.DIM - + Style.BRIGHT + + f"\033[{_ansi_colors['white']}m" + + _ANSI_BRIGHT + "%(name)s" - + Fore.RESET - + Style.RESET_ALL + + _ANSI_FG_RESET + + _ANSI_RESET + " " ) - module_funcName = Fore.GREEN + Style.BRIGHT + "%(module)s.%(funcName)s()" + module_funcName = ( + f"\033[{_ansi_colors['green']}m" + _ANSI_BRIGHT + "%(module)s.%(funcName)s()" + ) lineno = ( - Fore.BLACK - + Style.DIM - + Style.BRIGHT + f"\033[{_ansi_colors['black']}m" + + _ANSI_BRIGHT + ":" - + Style.RESET_ALL - + Fore.CYAN + + _ANSI_RESET + + f"\033[{_ansi_colors['cyan']}m" + "%(lineno)d" ) diff --git a/uv.lock b/uv.lock index 0542d5e6f4..df6b02b5b6 100644 --- a/uv.lock +++ b/uv.lock @@ -1382,7 +1382,6 @@ name = "tmuxp" version = "1.65.0" source = { editable = "." } dependencies = [ - { name = "colorama" }, { name = "libtmux" }, { name = "pyyaml" }, ] @@ -1420,7 +1419,6 @@ dev = [ { name = "sphinx-inline-tabs" }, { name = "sphinxext-opengraph" }, { name = "sphinxext-rediraffe" }, - { name = "types-colorama" }, { name = "types-docutils" }, { name = "types-pygments" }, { name = "types-pyyaml" }, @@ -1447,7 +1445,6 @@ docs = [ lint = [ { name = "mypy" }, { name = "ruff" }, - { name = "types-colorama" }, { name = "types-docutils" }, { name = "types-pygments" }, { name = "types-pyyaml" }, @@ -1462,7 +1459,6 @@ testing = [ [package.metadata] requires-dist = [ - { name = "colorama", specifier = ">=0.3.9" }, { name = "libtmux", specifier = "~=0.55.0" }, { name = "pyyaml", specifier = ">=6.0" }, ] @@ -1496,7 +1492,6 @@ dev = [ { name = "sphinx-inline-tabs" }, { name = "sphinxext-opengraph" }, { name = "sphinxext-rediraffe" }, - { name = "types-colorama" }, { name = "types-docutils" }, { name = "types-pygments" }, { name = "types-pyyaml" }, @@ -1519,7 +1514,6 @@ docs = [ lint = [ { name = "mypy" }, { name = "ruff" }, - { name = "types-colorama" }, { name = "types-docutils" }, { name = "types-pygments" }, { name = "types-pyyaml" }, @@ -1586,15 +1580,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, ] -[[package]] -name = "types-colorama" -version = "0.4.15.20250801" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/99/37/af713e7d73ca44738c68814cbacf7a655aa40ddd2e8513d431ba78ace7b3/types_colorama-0.4.15.20250801.tar.gz", hash = "sha256:02565d13d68963d12237d3f330f5ecd622a3179f7b5b14ee7f16146270c357f5", size = 10437, upload-time = "2025-08-01T03:48:22.605Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/95/3a/44ccbbfef6235aeea84c74041dc6dfee6c17ff3ddba782a0250e41687ec7/types_colorama-0.4.15.20250801-py3-none-any.whl", hash = "sha256:b6e89bd3b250fdad13a8b6a465c933f4a5afe485ea2e2f104d739be50b13eea9", size = 10743, upload-time = "2025-08-01T03:48:21.774Z" }, -] - [[package]] name = "types-docutils" version = "0.22.3.20260223" From ad99470b2b47b2a48820946bfcb3adb693f8ac5c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 14:48:56 -0500 Subject: [PATCH 08/50] feat(output[emit_object]): add OutputFormatter.emit_object and Colors.format_rule why: ls and debug-info bypassed OutputFormatter with raw sys.stdout.write, breaking the 2-channel output architecture for machine-readable output. what: - Add OutputFormatter.emit_object() for single top-level JSON objects - Route ls --json/--ndjson and debug-info --json through emit_object() - Add Colors.format_rule() for Unicode box-drawing horizontal rules - Add unit tests for emit_object in JSON, NDJSON, and HUMAN modes --- src/tmuxp/_internal/colors.py | 30 ++++++++++++++++++++++ src/tmuxp/cli/_output.py | 46 +++++++++++++++++++++++++++++++++ src/tmuxp/cli/debug_info.py | 10 ++++---- src/tmuxp/cli/ls.py | 12 ++++----- tests/cli/test_output.py | 48 +++++++++++++++++++++++++++++++++++ 5 files changed, 134 insertions(+), 12 deletions(-) diff --git a/src/tmuxp/_internal/colors.py b/src/tmuxp/_internal/colors.py index a25442d83c..73db6062fa 100644 --- a/src/tmuxp/_internal/colors.py +++ b/src/tmuxp/_internal/colors.py @@ -43,11 +43,14 @@ from __future__ import annotations import enum +import logging import os import re import sys import typing as t +logger = logging.getLogger(__name__) + if t.TYPE_CHECKING: from typing import TypeAlias @@ -470,6 +473,33 @@ def format_separator(self, length: int = 25) -> str: """ return self.muted("-" * length) + def format_rule(self, width: int = 40, char: str = "─") -> str: + """Format a horizontal rule using Unicode box-drawing characters. + + A richer alternative to ``format_separator()`` which uses plain hyphens. + + Parameters + ---------- + width : int + Number of characters. Default is 40. + char : str + Character to repeat. Default is ``"─"`` (U+2500). + + Returns + ------- + str + Muted (blue) rule when colors enabled, plain rule otherwise. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.format_rule(10) + '──────────' + >>> colors.format_rule(5, char="=") + '=====' + """ + return self.muted(char * width) + def format_kv(self, key: str, value: str) -> str: """Format key: value pair with syntax highlighting. diff --git a/src/tmuxp/cli/_output.py b/src/tmuxp/cli/_output.py index 7ac8df92ef..b62f1cc05c 100644 --- a/src/tmuxp/cli/_output.py +++ b/src/tmuxp/cli/_output.py @@ -25,9 +25,12 @@ import enum import json +import logging import sys import typing as t +logger = logging.getLogger(__name__) + class OutputMode(enum.Enum): """Output format modes for CLI commands. @@ -117,6 +120,49 @@ def emit_text(self, text: str) -> None: sys.stdout.write(text + "\n") sys.stdout.flush() + def emit_object(self, data: dict[str, t.Any]) -> None: + """Emit a single top-level JSON object (not a list of records). + + For commands that produce one structured object rather than a stream of + records. Writes immediately without buffering; does not affect + ``_json_buffer``. + + In JSON mode, writes indented JSON followed by a newline. + In NDJSON mode, writes compact single-line JSON followed by a newline. + In HUMAN mode, does nothing (use ``emit_text`` for human output). + + Parameters + ---------- + data : dict + The object to emit. + + Examples + -------- + >>> import io, sys + >>> formatter = OutputFormatter(OutputMode.JSON) + >>> formatter.emit_object({"status": "ok", "count": 3}) + { + "status": "ok", + "count": 3 + } + >>> formatter._json_buffer # buffer is unaffected + [] + + >>> formatter2 = OutputFormatter(OutputMode.NDJSON) + >>> formatter2.emit_object({"status": "ok", "count": 3}) + {"status": "ok", "count": 3} + + >>> formatter3 = OutputFormatter(OutputMode.HUMAN) + >>> formatter3.emit_object({"status": "ok"}) # no output in HUMAN mode + """ + if self.mode == OutputMode.JSON: + sys.stdout.write(json.dumps(data, indent=2) + "\n") + sys.stdout.flush() + elif self.mode == OutputMode.NDJSON: + sys.stdout.write(json.dumps(data) + "\n") + sys.stdout.flush() + # HUMAN: no-op + def finalize(self) -> None: """Finalize output (flush JSON buffer if needed). diff --git a/src/tmuxp/cli/debug_info.py b/src/tmuxp/cli/debug_info.py index 284ee462b3..8a69f81bdd 100644 --- a/src/tmuxp/cli/debug_info.py +++ b/src/tmuxp/cli/debug_info.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import logging import os import pathlib import platform @@ -17,8 +18,11 @@ from tmuxp._internal.private_path import PrivatePath, collapse_home_in_string from ._colors import Colors, build_description, get_color_mode +from ._output import OutputFormatter, OutputMode from .utils import tmuxp_echo +logger = logging.getLogger(__name__) + DEBUG_INFO_DESCRIPTION = build_description( """ Print diagnostic information for debugging and issue reports. @@ -243,9 +247,6 @@ def command_debug_info( parser: argparse.ArgumentParser | None = None, ) -> None: """Entrypoint for ``tmuxp debug-info`` to print debug info to submit with issues.""" - import json - import sys - # Get output mode output_json = args.output_json if args else False @@ -259,7 +260,6 @@ def command_debug_info( # Output based on mode if output_json: # Single object, not wrapped in array - sys.stdout.write(json.dumps(data, indent=2) + "\n") - sys.stdout.flush() + OutputFormatter(OutputMode.JSON).emit_object(data) else: tmuxp_echo(_format_human_output(data, colors)) diff --git a/src/tmuxp/cli/ls.py b/src/tmuxp/cli/ls.py index de8ec2dcd2..cd1ff5da75 100644 --- a/src/tmuxp/cli/ls.py +++ b/src/tmuxp/cli/ls.py @@ -29,6 +29,7 @@ import argparse import datetime import json +import logging import pathlib import typing as t @@ -46,6 +47,8 @@ from ._colors import Colors, build_description, get_color_mode from ._output import OutputFormatter, OutputMode, get_output_mode +logger = logging.getLogger(__name__) + LS_DESCRIPTION = build_description( """ List workspace files in the tmuxp configuration directory. @@ -567,9 +570,6 @@ def command_ls( -------- >>> # command_ls() lists workspaces from cwd/parents and ~/.tmuxp/ """ - import json - import sys - # Get color mode from args or default to AUTO color_mode = get_color_mode(args.color if args else None) colors = Colors(color_mode) @@ -612,8 +612,7 @@ def command_ls( "workspaces": [], "global_workspace_dirs": global_dir_candidates, } - sys.stdout.write(json.dumps(output_data, indent=2) + "\n") - sys.stdout.flush() + formatter.emit_object(output_data) # NDJSON: just output nothing for empty workspaces return @@ -623,8 +622,7 @@ def command_ls( "workspaces": workspaces, "global_workspace_dirs": global_dir_candidates, } - sys.stdout.write(json.dumps(output_data, indent=2) + "\n") - sys.stdout.flush() + formatter.emit_object(output_data) return # Human and NDJSON output diff --git a/tests/cli/test_output.py b/tests/cli/test_output.py index 3112f60d02..84bee97aa0 100644 --- a/tests/cli/test_output.py +++ b/tests/cli/test_output.py @@ -221,6 +221,54 @@ def test_ndjson_workflow(capsys: pytest.CaptureFixture[str]) -> None: assert captured.out == "" +def test_emit_object_json_writes_immediately( + capsys: pytest.CaptureFixture[str], +) -> None: + """JSON mode emit_object should write indented JSON immediately.""" + formatter = OutputFormatter(OutputMode.JSON) + formatter.emit_object({"status": "ok", "count": 3}) + + captured = capsys.readouterr() + data = json.loads(captured.out) + assert data == {"status": "ok", "count": 3} + # Indented output (indent=2) + assert "\n" in captured.out + + +def test_emit_object_ndjson_writes_compact( + capsys: pytest.CaptureFixture[str], +) -> None: + """NDJSON mode emit_object should write compact single-line JSON.""" + formatter = OutputFormatter(OutputMode.NDJSON) + formatter.emit_object({"status": "ok", "count": 3}) + + captured = capsys.readouterr() + lines = captured.out.strip().split("\n") + assert len(lines) == 1 + assert json.loads(lines[0]) == {"status": "ok", "count": 3} + + +def test_emit_object_human_silent(capsys: pytest.CaptureFixture[str]) -> None: + """HUMAN mode emit_object should produce no output.""" + formatter = OutputFormatter(OutputMode.HUMAN) + formatter.emit_object({"status": "ok"}) + + captured = capsys.readouterr() + assert captured.out == "" + + +def test_emit_object_does_not_buffer() -> None: + """emit_object must not affect _json_buffer.""" + formatter = OutputFormatter(OutputMode.JSON) + old_stdout = sys.stdout + sys.stdout = io.StringIO() + try: + formatter.emit_object({"status": "ok"}) + finally: + sys.stdout = old_stdout + assert formatter._json_buffer == [] + + def test_human_workflow(capsys: pytest.CaptureFixture[str]) -> None: """Test complete HUMAN output workflow.""" formatter = OutputFormatter(OutputMode.HUMAN) From fd390b60b8f1910e9fa58814cd15b86f3b92400e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 15:41:34 -0500 Subject: [PATCH 09/50] fix(logging[doctest]): add doctest to TmuxpLoggerAdapter why: CLAUDE.md requires all functions and methods to have working doctests. what: - Add Examples section to TmuxpLoggerAdapter demonstrating extra merging --- src/tmuxp/log.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/tmuxp/log.py b/src/tmuxp/log.py index cec5da5ac4..d4b9684957 100644 --- a/src/tmuxp/log.py +++ b/src/tmuxp/log.py @@ -30,6 +30,20 @@ class TmuxpLoggerAdapter(logging.LoggerAdapter): # type: ignore[type-arg] Follows the portable pattern to avoid repeating the same `extra` on every call while preserving the ability to add per-call `extra` kwargs. + + Examples + -------- + >>> adapter = TmuxpLoggerAdapter( + ... logging.getLogger("test"), + ... {"tmux_session": "my-session"}, + ... ) + >>> msg, kwargs = adapter.process("hello %s", {"extra": {"tmux_window": "editor"}}) + >>> msg + 'hello %s' + >>> kwargs["extra"]["tmux_session"] + 'my-session' + >>> kwargs["extra"]["tmux_window"] + 'editor' """ def process( From 1826606b5840ba02b252c55dd6e1dccf75601403 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 15:42:17 -0500 Subject: [PATCH 10/50] docs(logging[AGENTS]): add output channels section to AGENTS.md why: Developers need guidance on the 2-channel output architecture (logger for diagnostics, tmuxp_echo/OutputFormatter for user output). what: - Add "Output channels" section with diagnostics vs user-facing rules - Document print() prohibition in command/business logic - Expand "Avoid" entry for print() with structured extra guidance --- AGENTS.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 3fe760a6e0..bdbe24f926 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -206,12 +206,24 @@ Assert on `caplog.records` attributes, not string matching on `caplog.text`: - Assert on schema: `record.tmux_exit_code == 0` not `"exit code 0" in caplog.text` - `caplog.record_tuples` cannot access extra fields — always use `caplog.records` +### Output channels + +Two output channels serve different audiences: + +1. **Diagnostics** (`logger.*()` with `extra`): System events for log files, `caplog`, and aggregators. Never styled. +2. **User-facing output**: What the human sees. Styled via `Colors` class. + - Commands with output modes (`--json`/`--ndjson`): prefer `OutputFormatter.emit_text()` from `tmuxp.cli._output` — silenced in non-human modes. + - Human-only commands: use `tmuxp_echo()` from `tmuxp.log` (re-exported via `tmuxp.cli.utils`) for user-facing messages. + - **Undefined contracts:** Machine-output behavior for error and empty-result paths (e.g., `search` with no matches) is not yet defined. These paths currently emit styled text through `formatter.emit_text()`, which is a no-op in machine modes. + +Raw `print()` is forbidden in command/business logic. The `print()` call lives only inside the presenter layer (`_output.py`) or `tmuxp_echo`. + ### Avoid - f-strings/`.format()` in log calls - Unguarded logging in hot loops (guard with `isEnabledFor()`) - Catch-log-reraise without adding new context -- `print()` for diagnostics +- `print()` for debugging or internal diagnostics — use `logger.debug()` with structured `extra` instead - Logging secret env var values (log key names only) - Non-scalar ad-hoc objects in `extra` - Requiring custom `extra` fields in format strings without safe defaults (missing keys raise `KeyError`) From 50d87de2259531918c0f9a7f06cdd9bfff41db89 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 15:50:56 -0500 Subject: [PATCH 11/50] docs(CHANGES): add structured logging and colorama removal entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: PR 1 needs changelog entries for the logging work. what: - Add bug fixes: CLI log level, get_pane() exception, OutputFormatter routing - Add development section: structured logging, colorama removal, print→tmuxp_echo --- CHANGES | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/CHANGES b/CHANGES index 95d8c3e57e..627c4298cb 100644 --- a/CHANGES +++ b/CHANGES @@ -35,6 +35,26 @@ $ pipx install --suffix=@next 'tmuxp' --pip-args '\--pre' --force _Notes on the upcoming release will go here._ +### Bug fixes + +- Fix default CLI log level from INFO to WARNING so normal usage is not noisy (#1017) +- Suppress raw Python tracebacks on workspace build failure; error details available via `--log-level debug` while the user sees only `[Error] ` (#1017) +- Fix `get_pane()` to match sibling methods: widen catch to `Exception`, preserve exception chain via `from e`, replace bare `print()` with structured debug log (#1017) +- Route `ls --json` and `debug-info --json` through `OutputFormatter` for consistent machine-readable output (#1017) + +### Development + +#### Structured logging with `extra` context across all modules (#1017) + +All modules now use `logging.getLogger(__name__)` with structured `extra` keys +(`tmux_session`, `tmux_window`, `tmux_pane`, `tmux_config_path`, etc.) for +filtering and aggregation. Library `__init__.py` adds `NullHandler` per Python +best practices. A new `TmuxpLoggerAdapter` provides persistent context for +objects with stable identity. + +- Remove `colorama` runtime and type-stub dependencies; replace with stdlib ANSI constants (#1017) +- Route all raw `print()` calls through `tmuxp_echo()` for consistent output channels (#1017) + ## tmuxp 1.65.0 (2026-03-08) ### Breaking Changes From ed00d63da20e7fdcaea59c6a1a8c4cfb93c12403 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 16:31:09 -0500 Subject: [PATCH 12/50] fix(logging[load]): downgrade logger.exception to debug with exc_info why: logger.exception() dumps tracebacks at ERROR level (visible at default WARNING) alongside tmuxp_echo() user-friendly messages, causing users to see double output. what: - Change plugin load failed from exception to debug with exc_info - Change plugin import failed from exception to debug with exc_info - Change workspace build failed from exception to debug with exc_info --- src/tmuxp/cli/load.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index b8184999eb..97dd486710 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -123,7 +123,7 @@ def load_plugins( module_name = ".".join(module_name[:-1]) plugin_name = plugin.split(".")[-1] except AttributeError as error: - logger.exception("plugin load failed") + logger.debug("plugin load failed", exc_info=True) tmuxp_echo( colors.error("[Plugin Error]") + f" Couldn't load {plugin}\n" @@ -149,7 +149,7 @@ def load_plugins( ) sys.exit(1) except (ImportError, AttributeError) as error: - logger.exception("plugin import failed") + logger.debug("plugin import failed", exc_info=True) tmuxp_echo( colors.error("[Plugin Error]") + f" Couldn't load {plugin}\n" @@ -453,7 +453,7 @@ def load_workspace( _load_attached(builder, detached) except exc.TmuxpException as e: - logger.exception("workspace build failed") + logger.debug("workspace build failed", exc_info=True) tmuxp_echo(cli_colors.error("[Error]") + f" {e}") choice = prompt_choices( From 476a550dec569e9d934d81e251925a51d28b59ba Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 16:31:31 -0500 Subject: [PATCH 13/50] fix(logging[load]): restore user-facing [Loading] message why: The structured logging migration removed the user-visible [Loading] message that shows which workspace file is being loaded. what: - Add tmuxp_echo with [Loading] and privacy-masked workspace path - Uses PrivatePath (already imported) and cli_colors (already available) --- src/tmuxp/cli/load.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index 97dd486710..5cc145e80a 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -358,6 +358,11 @@ def load_workspace( "loading workspace", extra={"tmux_config_path": str(workspace_file)}, ) + tmuxp_echo( + cli_colors.info("[Loading]") + + " " + + cli_colors.highlight(str(PrivatePath(workspace_file))), + ) # ConfigReader allows us to open a yaml or json file as a dict raw_workspace = config_reader.ConfigReader._from_file(workspace_file) or {} From 9ee0acc4f5844bca4752fbf8b7a8e908263f40b2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 16:31:57 -0500 Subject: [PATCH 14/50] fix(logging[load]): move reattach output to structured extra why: Inlining tmux stdout in the log message string defeats structured log aggregation and filtering. what: - Move display-message output from format arg to extra tmux_stdout key --- src/tmuxp/cli/load.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index 5cc145e80a..4673c4cde6 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -184,7 +184,10 @@ def _reattach(builder: WorkspaceBuilder, colors: Colors | None = None) -> None: proc = builder.session.cmd("display-message", "-p", "'#S'") for line in proc.stdout: tmuxp_echo(colors.info(line) if colors else line) - logger.debug("reattach display-message output: %s", line.strip()) + logger.debug( + "reattach display-message output", + extra={"tmux_stdout": [line.strip()]}, + ) if "TMUX" in os.environ: builder.session.switch_client() From 16fb89a1dbb89b38d00733f2ccf68e2995c3a269 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 16:32:17 -0500 Subject: [PATCH 15/50] fix(logging[doctest]): add doctest to _identity why: All functions must have working doctests per project conventions. what: - Add Examples section with string and integer pass-through tests --- src/tmuxp/_compat.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/tmuxp/_compat.py b/src/tmuxp/_compat.py index 7888a50b46..51a055f7f5 100644 --- a/src/tmuxp/_compat.py +++ b/src/tmuxp/_compat.py @@ -11,7 +11,22 @@ def _identity(x: object) -> object: - """Return *x* unchanged — used as a no-op decorator.""" + """Return *x* unchanged — used as a no-op decorator. + + Examples + -------- + >>> from tmuxp._compat import _identity + + Strings pass through unchanged: + + >>> _identity("hello") + 'hello' + + Integers pass through unchanged: + + >>> _identity(42) + 42 + """ return x From 689b5bceebc19911621ce04038610d754d9b83c9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 16:33:05 -0500 Subject: [PATCH 16/50] fix(logging[builder]): add script path to before_script error extra why: The bare error message loses the script path, making it harder to diagnose which before_script failed in structured log systems. what: - Add tmux_config_path extra with the before_script path to error log --- src/tmuxp/workspace/builder.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 18154b997a..57d494c01f 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -366,7 +366,14 @@ def build(self, session: Session | None = None, append: bool = False) -> None: ) run_before_script(self.session_config["before_script"], cwd=cwd) except Exception: - _log.error("before script failed") + _log.error( + "before script failed", + extra={ + "tmux_config_path": str( + self.session_config["before_script"], + ), + }, + ) self.session.kill() raise From df72c8408c25b491b9892a1247c0bb809d9aecd1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 16:33:29 -0500 Subject: [PATCH 17/50] fix(logging[util]): remove redundant exc_info from pane lookup reraise why: The raise...from e chain on the next lines already preserves the exception traceback; logging it too is redundant per project standards. what: - Remove exc_info=True from pane lookup debug log before reraise --- src/tmuxp/util.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/tmuxp/util.py b/src/tmuxp/util.py index f069a47aec..d63a6c4182 100644 --- a/src/tmuxp/util.py +++ b/src/tmuxp/util.py @@ -195,7 +195,6 @@ def get_pane(window: Window, current_pane: Pane | None = None) -> Pane: except Exception as e: logger.debug( "pane lookup failed", - exc_info=True, extra={"tmux_pane": str(current_pane) if current_pane else ""}, ) if current_pane: From 1f38d39379ccc7b3de9f256b53e61c21a7fc0617 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 16:33:51 -0500 Subject: [PATCH 18/50] fix(logging[test]): update stale assertion in skipped test why: The assertion checked for "Loading" or "Loaded" but the restored user-facing message now uses "[Loading]" format. what: - Update assertion to match the restored [Loading] message format --- tests/cli/test_load.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/cli/test_load.py b/tests/cli/test_load.py index 9ecffd2471..60c2c446ed 100644 --- a/tests/cli/test_load.py +++ b/tests/cli/test_load.py @@ -582,7 +582,7 @@ def test_load_plugins_version_fail_skip( result = capsys.readouterr() - assert "Loading" in result.out or "Loaded" in result.out + assert "[Loading]" in result.out PLUGIN_VERSION_NO_SKIP_TEST_FIXTURES: list[PluginVersionTestFixture] = [ From e23569d434cd3cddb19d20aabb6907036dfb9ac8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 17:01:42 -0500 Subject: [PATCH 19/50] Tag v1.66.0 (logging updates via #1017) --- CHANGES | 4 +++- pyproject.toml | 2 +- src/tmuxp/__about__.py | 2 +- uv.lock | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGES b/CHANGES index 627c4298cb..5e8bf5cdcd 100644 --- a/CHANGES +++ b/CHANGES @@ -27,7 +27,7 @@ $ pipx install --suffix=@next 'tmuxp' --pip-args '\--pre' --force // Usage: tmuxp@next load yoursession ``` -## tmuxp 1.66.0 (Yet to be released) +## tmuxp 1.67.0 (Yet to be released) @@ -35,6 +35,8 @@ $ pipx install --suffix=@next 'tmuxp' --pip-args '\--pre' --force _Notes on the upcoming release will go here._ +## tmuxp 1.66.0 (2026-03-08) + ### Bug fixes - Fix default CLI log level from INFO to WARNING so normal usage is not noisy (#1017) diff --git a/pyproject.toml b/pyproject.toml index aa884dea6a..ceb1aae0c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tmuxp" -version = "1.65.0" +version = "1.66.0" description = "Session manager for tmux, which allows users to save and load tmux sessions through simple configuration files." requires-python = ">=3.10,<4.0" authors = [ diff --git a/src/tmuxp/__about__.py b/src/tmuxp/__about__.py index cb95133689..f1cfd86085 100644 --- a/src/tmuxp/__about__.py +++ b/src/tmuxp/__about__.py @@ -8,7 +8,7 @@ __title__ = "tmuxp" __package_name__ = "tmuxp" -__version__ = "1.65.0" +__version__ = "1.66.0" __description__ = "tmux session manager" __email__ = "tony@git-pull.com" __author__ = "Tony Narlock" diff --git a/uv.lock b/uv.lock index df6b02b5b6..03668ca9d4 100644 --- a/uv.lock +++ b/uv.lock @@ -1379,7 +1379,7 @@ wheels = [ [[package]] name = "tmuxp" -version = "1.65.0" +version = "1.66.0" source = { editable = "." } dependencies = [ { name = "libtmux" }, From 46cf41104ace100a8953eedf54685dcdc9e722b2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 14:49:15 -0500 Subject: [PATCH 20/50] feat(builder[callbacks]): add progress, before_script, script_output, and build_event callbacks why: The builder needs to emit lifecycle events so a UI layer can render real-time progress without coupling builder logic to display code. what: - Add on_progress, on_before_script, on_script_output, on_build_event callback parameters to WorkspaceBuilder - Emit structured build events: session_created (with window_total, session_pane_total), window_started, pane_creating, window_done, workspace_built, before_script_started, before_script_done - Add on_line callback to run_before_script() for capturing script output - Add doctests for all callback types --- src/tmuxp/util.py | 14 ++- src/tmuxp/workspace/builder.py | 180 ++++++++++++++++++++++++++++++++- 2 files changed, 190 insertions(+), 4 deletions(-) diff --git a/src/tmuxp/util.py b/src/tmuxp/util.py index d63a6c4182..152b1f6c06 100644 --- a/src/tmuxp/util.py +++ b/src/tmuxp/util.py @@ -28,8 +28,12 @@ def run_before_script( script_file: str | pathlib.Path, cwd: pathlib.Path | None = None, + on_line: t.Callable[[str], None] | None = None, ) -> int: - """Execute shell script, ``tee``-ing output to both terminal (if TTY) and buffer.""" + """Execute shell script, streaming output to callback or terminal (if TTY). + + Output is buffered and optionally forwarded via the ``on_line`` callback. + """ script_cmd = shlex.split(str(script_file)) try: @@ -68,13 +72,17 @@ def run_before_script( if line_out and line_out.strip(): out_buffer.append(line_out) - if is_out_tty: + if on_line is not None: + on_line(line_out) + elif is_out_tty: sys.stdout.write(line_out) sys.stdout.flush() if line_err and line_err.strip(): err_buffer.append(line_err) - if is_err_tty: + if on_line is not None: + on_line(line_err) + elif is_err_tty: sys.stderr.write(line_err) sys.stderr.flush() diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 57d494c01f..728b477963 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -169,6 +169,101 @@ class WorkspaceBuilder: >>> sorted([window.name for window in session.windows]) ['editor', 'logging', 'test'] + **Progress callback:** + + >>> calls: list[str] = [] + >>> progress_cfg = { + ... "session_name": "progress-demo", + ... "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + ... } + >>> builder = WorkspaceBuilder( + ... session_config=progress_cfg, + ... server=server, + ... on_progress=calls.append, + ... ) + >>> builder.build() + >>> "Workspace built" in calls + True + + **Before-script hook:** + + >>> hook_calls: list[bool] = [] + >>> no_script_cfg = { + ... "session_name": "hook-demo", + ... "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + ... } + >>> builder = WorkspaceBuilder( + ... session_config=no_script_cfg, + ... server=server, + ... on_before_script=lambda: hook_calls.append(True), + ... ) + >>> builder.build() + >>> hook_calls # no before_script in config, callback not fired + [] + + **Script output hook:** + + >>> script_lines: list[str] = [] + >>> no_script_cfg2 = { + ... "session_name": "script-output-demo", + ... "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + ... } + >>> builder = WorkspaceBuilder( + ... session_config=no_script_cfg2, + ... server=server, + ... on_script_output=script_lines.append, + ... ) + >>> builder.build() + >>> script_lines # no before_script in config, callback not fired + [] + + **Build events hook:** + + >>> events: list[dict] = [] + >>> event_cfg = { + ... "session_name": "events-demo", + ... "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + ... } + >>> builder = WorkspaceBuilder( + ... session_config=event_cfg, + ... server=server, + ... on_build_event=events.append, + ... ) + >>> builder.build() + >>> [e["event"] for e in events] + ['session_created', 'window_started', 'pane_creating', + 'window_done', 'workspace_built'] + >>> next(e for e in events if e["event"] == "session_created")["session_pane_total"] + 1 + + **Build events with before_script:** + + ``before_script_started`` fires before the script runs; + ``before_script_done`` fires in ``finally`` (success or failure). + + >>> script_events: list[dict] = [] + >>> script_event_cfg = { + ... "session_name": "script-events-demo", + ... "before_script": "echo hello", + ... "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + ... } + >>> builder = WorkspaceBuilder( + ... session_config=script_event_cfg, + ... server=server, + ... on_build_event=script_events.append, + ... ) + >>> builder.build() + >>> event_names = [e["event"] for e in script_events] + >>> "before_script_started" in event_names + True + >>> "before_script_done" in event_names + True + >>> bs_start = event_names.index("before_script_started") + >>> bs_done = event_names.index("before_script_done") + >>> win_start = event_names.index("window_started") + >>> bs_start < bs_done < win_start + True + The normal phase of loading is: 1. Load JSON / YAML file via :class:`pathlib.Path`:: @@ -211,12 +306,20 @@ class WorkspaceBuilder: server: Server _session: Session | None session_name: str + on_progress: t.Callable[[str], None] | None + on_before_script: t.Callable[[], None] | None + on_script_output: t.Callable[[str], None] | None + on_build_event: t.Callable[[dict[str, t.Any]], None] | None def __init__( self, session_config: dict[str, t.Any], server: Server, plugins: list[t.Any] | None = None, + on_progress: t.Callable[[str], None] | None = None, + on_before_script: t.Callable[[], None] | None = None, + on_script_output: t.Callable[[str], None] | None = None, + on_build_event: t.Callable[[dict[str, t.Any]], None] | None = None, ) -> None: """Initialize workspace loading. @@ -231,6 +334,23 @@ def __init__( server : :class:`libtmux.Server` tmux server to build session in + on_progress : callable, optional + callback for progress updates during building + + on_before_script : callable, optional + called just before ``before_script`` runs; use to clear the terminal + (e.g. stop a spinner) so script output is not interleaved + + on_script_output : callable, optional + called with each output line from ``before_script`` subprocess; when + set, raw TTY tee is suppressed so the caller can route lines to a + live panel instead + + on_build_event : callable, optional + called with a dict event at each structural build milestone (session + created, window started/done, pane creating, workspace built); used + by the CLI to render a live session tree + Notes ----- TODO: Initialize :class:`libtmux.Session` from here, in @@ -248,6 +368,10 @@ def __init__( self.session_config = session_config self.plugins = plugins + self.on_progress = on_progress + self.on_before_script = on_before_script + self.on_script_output = on_script_output + self.on_build_event = on_build_event if self.server is not None and self.session_exists( session_name=self.session_config["session_name"], @@ -332,7 +456,21 @@ def build(self, session: Session | None = None, append: bool = False) -> None: assert session is not None assert session.name is not None + if self.on_progress: + self.on_progress(f"Session created: {session.name}") + self._session = session + if self.on_build_event: + self.on_build_event( + { + "event": "session_created", + "name": session.name, + "window_total": len(self.session_config["windows"]), + "session_pane_total": sum( + len(w.get("panes", [])) for w in self.session_config["windows"] + ), + } + ) _log = TmuxpLoggerAdapter( logger, {"tmux_session": self.session_config["session_name"]}, @@ -354,6 +492,10 @@ def build(self, session: Session | None = None, append: bool = False) -> None: focus = None if "before_script" in self.session_config: + if self.on_before_script: + self.on_before_script() + if self.on_build_event: + self.on_build_event({"event": "before_script_started"}) try: cwd = None @@ -364,7 +506,11 @@ def build(self, session: Session | None = None, append: bool = False) -> None: _log.debug( "running before script", ) - run_before_script(self.session_config["before_script"], cwd=cwd) + run_before_script( + self.session_config["before_script"], + cwd=cwd, + on_line=self.on_script_output, + ) except Exception: _log.error( "before script failed", @@ -376,6 +522,9 @@ def build(self, session: Session | None = None, append: bool = False) -> None: ) self.session.kill() raise + finally: + if self.on_build_event: + self.on_build_event({"event": "before_script_done"}) if "options" in self.session_config: for option, value in self.session_config["options"].items(): @@ -414,10 +563,17 @@ def build(self, session: Session | None = None, append: bool = False) -> None: if focus_pane: focus_pane.select() + if self.on_build_event: + self.on_build_event({"event": "window_done"}) + if focus: focus.select() + if self.on_progress: + self.on_progress("Workspace built") _log.info("workspace built") + if self.on_build_event: + self.on_build_event({"event": "workspace_built"}) def iter_create_windows( self, @@ -450,6 +606,17 @@ def iter_create_windows( ): window_name = window_config.get("window_name", None) + if self.on_progress: + self.on_progress(f"Creating window: {window_name or window_iterator}") + if self.on_build_event: + self.on_build_event( + { + "event": "window_started", + "name": window_name or str(window_iterator), + "pane_total": len(window_config["panes"]), + } + ) + is_first_window_pass = self.first_window_pass( window_iterator, session, @@ -545,6 +712,17 @@ def iter_create_panes( window_config["panes"], start=pane_base_index, ): + if self.on_progress: + self.on_progress(f"Creating pane: {pane_index}") + if self.on_build_event: + self.on_build_event( + { + "event": "pane_creating", + "pane_num": pane_index - int(pane_base_index) + 1, + "pane_total": len(window_config["panes"]), + } + ) + if pane_index == int(pane_base_index): pane = window.active_pane else: From ac1f73b73b9bf06532e61de8f48cfa124b829366 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 14:49:26 -0500 Subject: [PATCH 21/50] feat(_progress[spinner]): add Spinner, BuildTree, templates, and presets why: The CLI needs an animated progress display during workspace builds. A dedicated module keeps display logic decoupled from builder and load. what: - Add Spinner context manager with atexit cursor restore and non-TTY fallback - Add BuildTree for tracking build state (session, windows, panes) - Add scrolling output panel for before_script output lines - Add PROGRESS_PRESETS (default, minimal, window, pane, verbose) with format_template using {session}, {window}, {bar}, {progress}, etc. - Add render_bar() with marching indicator during before_script - Add SUCCESS_TEMPLATE and format_success() for persistent completion line - Add _SafeFormatMap, ANSI-aware truncation, dynamic terminal width refresh --- src/tmuxp/_internal/colors.py | 1 + src/tmuxp/cli/_colors.py | 2 + src/tmuxp/cli/_progress.py | 1131 +++++++++++++++++++++++++++++++++ 3 files changed, 1134 insertions(+) create mode 100644 src/tmuxp/cli/_progress.py diff --git a/src/tmuxp/_internal/colors.py b/src/tmuxp/_internal/colors.py index 73db6062fa..a9b350016f 100644 --- a/src/tmuxp/_internal/colors.py +++ b/src/tmuxp/_internal/colors.py @@ -610,6 +610,7 @@ def get_color_mode(color_arg: str | None = None) -> ColorMode: # ANSI styling utilities (originally from click, via utils.py) _ansi_re = re.compile(r"\033\[[;?0-9]*[a-zA-Z]") +ANSI_SEQ_RE = _ansi_re def strip_ansi(value: str) -> str: diff --git a/src/tmuxp/cli/_colors.py b/src/tmuxp/cli/_colors.py index 2513d118a8..31dab82076 100644 --- a/src/tmuxp/cli/_colors.py +++ b/src/tmuxp/cli/_colors.py @@ -12,6 +12,7 @@ import logging from tmuxp._internal.colors import ( + ANSI_SEQ_RE, ColorMode, Colors, UnknownStyleColor, @@ -25,6 +26,7 @@ logger = logging.getLogger(__name__) __all__ = [ + "ANSI_SEQ_RE", "ColorMode", "Colors", "UnknownStyleColor", diff --git a/src/tmuxp/cli/_progress.py b/src/tmuxp/cli/_progress.py new file mode 100644 index 0000000000..01d31d7272 --- /dev/null +++ b/src/tmuxp/cli/_progress.py @@ -0,0 +1,1131 @@ +"""Progress indicators for tmuxp CLI. + +This module provides a threaded spinner for long-running operations, +using only standard library and ANSI escape sequences. +""" + +from __future__ import annotations + +import atexit +import collections +import dataclasses +import itertools +import logging +import shutil +import sys +import threading +import time +import typing as t + +from ._colors import ANSI_SEQ_RE, ColorMode, Colors, strip_ansi + +logger = logging.getLogger(__name__) + + +if t.TYPE_CHECKING: + import types + + +# ANSI Escape Sequences +HIDE_CURSOR = "\033[?25l" +SHOW_CURSOR = "\033[?25h" +ERASE_LINE = "\033[2K" +CURSOR_TO_COL0 = "\r" +CURSOR_UP_1 = "\x1b[1A" +SYNC_START = "\x1b[?2026h" # synchronized output: buffer until SYNC_END +SYNC_END = "\x1b[?2026l" # flush — prevents multi-line flicker + +# Spinner frames (braille pattern) +SPINNER_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" + +BAR_WIDTH = 10 # inner fill character count +DEFAULT_OUTPUT_LINES = 3 # default spinner panel height (lines of script output) + + +def _visible_len(s: str) -> int: + r"""Return visible length of *s*, ignoring ANSI escapes. + + Examples + -------- + >>> _visible_len("hello") + 5 + >>> _visible_len("\033[32mgreen\033[0m") + 5 + >>> _visible_len("") + 0 + """ + return len(strip_ansi(s)) + + +def _truncate_visible(text: str, max_visible: int, suffix: str = "...") -> str: + r"""Truncate *text* to *max_visible* visible characters, preserving ANSI sequences. + + If the visible length of *text* is already within *max_visible*, it is + returned unchanged. Otherwise the text is cut so that exactly + *max_visible* visible characters remain, a ``\x1b[0m`` reset is appended + (to prevent color bleed), followed by *suffix*. + + Parameters + ---------- + text : str + Input string, possibly containing ANSI escape sequences. + max_visible : int + Maximum number of visible (non-ANSI) characters to keep. + suffix : str + Appended after the reset when truncation occurs. Default ``"..."``. + + Returns + ------- + str + Truncated string with ANSI sequences intact. + + Examples + -------- + Plain text truncation: + + >>> _truncate_visible("hello world", 5) + 'hello\x1b[0m...' + + ANSI sequences are preserved whole: + + >>> _truncate_visible("\033[32mgreen\033[0m", 3) + '\x1b[32mgre\x1b[0m...' + + No truncation needed: + + >>> _truncate_visible("short", 10) + 'short' + + Empty string: + + >>> _truncate_visible("", 5) + '' + """ + if max_visible <= 0: + return "" + if _visible_len(text) <= max_visible: + return text + + result: list[str] = [] + visible = 0 + i = 0 + while i < len(text) and visible < max_visible: + m = ANSI_SEQ_RE.match(text, i) + if m: + result.append(m.group()) + i = m.end() + else: + result.append(text[i]) + visible += 1 + i += 1 + return "".join(result) + "\x1b[0m" + suffix + + +SUCCESS_TEMPLATE = "Loaded workspace: {session} ({workspace_path}) {summary}" + +PROGRESS_PRESETS: dict[str, str] = { + "default": "Loading workspace: {session} {bar} {progress} {window}", + "minimal": "Loading workspace: {session} [{window_progress}]", + "window": "Loading workspace: {session} {window_bar} {window_progress_rel}", + "pane": "Loading workspace: {session} {pane_bar} {session_pane_progress}", + "verbose": ( + "Loading workspace: {session} [window {window_index} of {window_total}" + " · pane {pane_index} of {pane_total}] {window}" + ), +} + + +def render_bar(done: int, total: int, width: int = BAR_WIDTH) -> str: + """Render a plain-text ASCII progress bar without color. + + Parameters + ---------- + done : int + Completed units. + total : int + Total units. When ``<= 0``, returns ``""``. + width : int + Inner fill character count; default :data:`BAR_WIDTH`. + + Returns + ------- + str + A bar like ``"█████░░░░░"``. + Returns ``""`` when *total* <= 0 or *width* <= 0. + + Examples + -------- + >>> render_bar(0, 10) + '░░░░░░░░░░' + >>> render_bar(5, 10) + '█████░░░░░' + >>> render_bar(10, 10) + '██████████' + >>> render_bar(0, 0) + '' + >>> render_bar(3, 10, width=5) + '█░░░░' + """ + if total <= 0 or width <= 0: + return "" + filled = min(width, int(done / total * width)) + return "█" * filled + "░" * (width - filled) + + +class _SafeFormatMap(dict): # type: ignore[type-arg] + """dict subclass that returns ``{key}`` for missing keys in format_map.""" + + def __missing__(self, key: str) -> str: + return "{" + key + "}" + + +def resolve_progress_format(fmt: str) -> str: + """Return the format string for *fmt*, resolving preset names. + + If *fmt* is a key in :data:`PROGRESS_PRESETS` the corresponding + format string is returned; otherwise *fmt* is returned as-is. + + Examples + -------- + >>> resolve_progress_format("minimal") == PROGRESS_PRESETS["minimal"] + True + >>> resolve_progress_format("{session} w{window_progress}") + '{session} w{window_progress}' + >>> resolve_progress_format("unknown-preset") + 'unknown-preset' + """ + return PROGRESS_PRESETS.get(fmt, fmt) + + +@dataclasses.dataclass +class _WindowStatus: + """State for a single window in the build tree.""" + + name: str + done: bool = False + pane_num: int | None = None + pane_total: int | None = None + pane_done: int = 0 # panes completed in this window (set on window_done) + + +class BuildTree: + """Tracks session/window/pane build state; renders a structural progress tree. + + **Template Token Lifecycle** + + Each token is first available at the event listed in its column. + ``—`` means the value does not change at that phase. + + .. list-table:: + :header-rows: 1 + + * - Token + - Pre-``session_created`` + - After ``session_created`` + - After ``window_started`` + - After ``pane_creating`` + - After ``window_done`` + * - ``{session}`` + - ``""`` + - session name + - — + - — + - — + * - ``{window}`` + - ``""`` + - ``""`` + - window name + - — + - last window name + * - ``{window_index}`` + - ``0`` + - ``0`` + - N (1-based started count) + - — + - — + * - ``{window_total}`` + - ``0`` + - total + - — + - — + - — + * - ``{window_progress}`` + - ``""`` + - ``""`` + - ``"N/M"`` when > 0 + - — + - — + * - ``{windows_done}`` + - ``0`` + - ``0`` + - ``0`` + - ``0`` + - increments + * - ``{windows_remaining}`` + - ``0`` + - total + - total + - total + - decrements + * - ``{window_progress_rel}`` + - ``""`` + - ``"0/M"`` + - ``"0/M"`` + - — + - ``"N/M"`` + * - ``{pane_index}`` + - ``0`` + - ``0`` + - ``0`` + - pane_num + - ``0`` + * - ``{pane_total}`` + - ``0`` + - ``0`` + - window's pane total + - — + - window's pane total + * - ``{pane_progress}`` + - ``""`` + - ``""`` + - ``""`` + - ``"N/M"`` + - ``""`` + * - ``{pane_done}`` + - ``0`` + - ``0`` + - ``0`` + - pane_num + - pane_total + * - ``{pane_remaining}`` + - ``0`` + - ``0`` + - pane_total + - decrements + - ``0`` + * - ``{pane_progress_rel}`` + - ``""`` + - ``""`` + - ``"0/M"`` + - ``"N/M"`` + - ``"M/M"`` + * - ``{progress}`` + - ``""`` + - ``""`` + - ``"N/M win"`` + - ``"N/M win · P/Q pane"`` + - — + * - ``{session_pane_total}`` + - ``0`` + - total + - — + - — + - — + * - ``{session_panes_done}`` + - ``0`` + - ``0`` + - ``0`` + - ``0`` + - accumulated + * - ``{session_panes_remaining}`` + - ``0`` + - total + - total + - total + - decrements + * - ``{session_pane_progress}`` + - ``""`` + - ``"0/T"`` + - — + - — + - ``"N/T"`` + * - ``{overall_percent}`` + - ``0`` + - ``0`` + - ``0`` + - ``0`` + - updates + * - ``{summary}`` + - ``""`` + - ``""`` + - ``""`` + - ``""`` + - ``"[N win, M panes]"`` + * - ``{bar}`` (spinner) + - ``[░░…]`` + - ``[░░…]`` + - starts filling + - fractional + - jumps + * - ``{pane_bar}`` (spinner) + - ``""`` + - ``[░░…]`` + - — + - — + - updates + * - ``{window_bar}`` (spinner) + - ``""`` + - ``[░░…]`` + - — + - — + - updates + * - ``{status_icon}`` (spinner) + - ``""`` + - ``""`` + - ``""`` + - ``""`` + - ``""`` + + During ``before_script``: ``{bar}``, ``{pane_bar}``, ``{window_bar}`` show a + marching animation; ``{status_icon}`` = ``⏸``. + + Examples + -------- + Empty tree renders nothing: + + >>> from tmuxp.cli._colors import ColorMode, Colors + >>> colors = Colors(ColorMode.NEVER) + >>> tree = BuildTree() + >>> tree.render(colors, 80) + [] + + After session_created event the header appears: + + >>> tree.on_event({"event": "session_created", "name": "my-session"}) + >>> tree.render(colors, 80) + ['Session'] + + After window_started and pane_creating: + + >>> tree.on_event({"event": "window_started", "name": "editor", "pane_total": 2}) + >>> tree.on_event({"event": "pane_creating", "pane_num": 1, "pane_total": 2}) + >>> lines = tree.render(colors, 80) + >>> lines[1] + '- editor, pane (1 of 2)' + + After window_done the window gets a checkmark: + + >>> tree.on_event({"event": "window_done"}) + >>> lines = tree.render(colors, 80) + >>> lines[1] + '- ✓ editor' + + **Inline status format:** + + >>> tree2 = BuildTree() + >>> tree2.format_inline("Building projects...") + 'Building projects...' + >>> tree2.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + >>> tree2.format_inline("Building projects...") + 'Building projects... cihai' + >>> tree2.on_event({"event": "window_started", "name": "gp-libs", "pane_total": 2}) + >>> tree2.on_event({"event": "pane_creating", "pane_num": 1, "pane_total": 2}) + >>> tree2.format_inline("Building projects...") + 'Building projects... cihai [1 of 3 windows, 1 of 2 panes] gp-libs' + """ + + def __init__(self, workspace_path: str = "") -> None: + self.workspace_path: str = workspace_path + self.session_name: str | None = None + self.windows: list[_WindowStatus] = [] + self.window_total: int | None = None + self.session_pane_total: int | None = None + self.session_panes_done: int = 0 + self.windows_done: int = 0 + self._before_script_event: threading.Event = threading.Event() + + def on_event(self, event: dict[str, t.Any]) -> None: + """Update tree state from a build event dict. + + Examples + -------- + >>> tree = BuildTree() + >>> tree.on_event({ + ... "event": "session_created", "name": "dev", "window_total": 2, + ... }) + >>> tree.session_name + 'dev' + >>> tree.window_total + 2 + >>> tree.on_event({ + ... "event": "window_started", "name": "editor", "pane_total": 3, + ... }) + >>> len(tree.windows) + 1 + >>> tree.windows[0].name + 'editor' + """ + kind = event["event"] + if kind == "session_created": + self.session_name = event["name"] + self.window_total = event.get("window_total") + self.session_pane_total = event.get("session_pane_total") + elif kind == "before_script_started": + self._before_script_event.set() + elif kind == "before_script_done": + self._before_script_event.clear() + elif kind == "window_started": + self.windows.append( + _WindowStatus(name=event["name"], pane_total=event["pane_total"]) + ) + elif kind == "pane_creating": + if self.windows: + w = self.windows[-1] + w.pane_num = event["pane_num"] + w.pane_total = event["pane_total"] + elif kind == "window_done": + if self.windows: + w = self.windows[-1] + w.done = True + w.pane_num = None + w.pane_done = w.pane_total or 0 + self.session_panes_done += w.pane_done + self.windows_done += 1 + elif kind == "workspace_built": + for w in self.windows: + w.done = True + + def render(self, colors: Colors, width: int) -> list[str]: + """Render the current tree state to a list of display strings. + + Parameters + ---------- + colors : Colors + Colors instance for ANSI styling. + width : int + Terminal width; window lines are truncated to ``width - 1``. + + Returns + ------- + list[str] + Lines to display; empty list if no session has been created yet. + """ + if self.session_name is None: + return [] + lines: list[str] = [colors.heading("Session")] + for w in self.windows: + if w.done: + line = f"- {colors.success('✓')} {colors.highlight(w.name)}" + elif w.pane_num is not None and w.pane_total is not None: + line = ( + f"- {colors.highlight(w.name)}" + f"{colors.muted(f', pane ({w.pane_num} of {w.pane_total})')}" + ) + else: + line = f"- {colors.highlight(w.name)}" + lines.append(_truncate_visible(line, width - 1, suffix="")) + return lines + + def _context(self) -> dict[str, t.Any]: + """Return the current build-state token dict for template rendering. + + Examples + -------- + Zero-state before any events: + + >>> tree = BuildTree(workspace_path="~/.tmuxp/myapp.yaml") + >>> ev = { + ... "event": "session_created", + ... "name": "myapp", + ... "window_total": 5, + ... "session_pane_total": 10, + ... } + >>> tree.on_event(ev) + >>> ctx = tree._context() + >>> ctx["workspace_path"] + '~/.tmuxp/myapp.yaml' + >>> ctx["session"] + 'myapp' + >>> ctx["window_total"] + 5 + >>> ctx["window_index"] + 0 + >>> ctx["progress"] + '' + >>> ctx["windows_done"] + 0 + >>> ctx["windows_remaining"] + 5 + >>> ctx["window_progress_rel"] + '0/5' + >>> ctx["session_pane_total"] + 10 + >>> ctx["session_panes_remaining"] + 10 + >>> ctx["session_pane_progress"] + '0/10' + >>> ctx["summary"] + '' + + After windows complete, summary shows counts: + + >>> tree.on_event({"event": "window_started", "name": "w1", "pane_total": 3}) + >>> tree.on_event({"event": "window_done"}) + >>> tree.on_event({"event": "window_started", "name": "w2", "pane_total": 5}) + >>> tree.on_event({"event": "window_done"}) + >>> tree._context()["summary"] + '[2 win, 8 panes]' + """ + w = self.windows[-1] if self.windows else None + window_idx = len(self.windows) + win_tot = self.window_total or 0 + pane_idx = (w.pane_num or 0) if w else 0 + pane_tot = (w.pane_total or 0) if w else 0 + + win_progress = f"{window_idx}/{win_tot}" if win_tot and window_idx > 0 else "" + pane_progress = f"{pane_idx}/{pane_tot}" if pane_tot and pane_idx > 0 else "" + progress_parts = [ + f"{win_progress} win" if win_progress else "", + f"{pane_progress} pane" if pane_progress else "", + ] + progress = " · ".join(p for p in progress_parts if p) + + win_done = self.windows_done + win_progress_rel = f"{win_done}/{win_tot}" if win_tot else "" + + pane_done_cur = ( + (w.pane_num or 0) if w and not w.done else (w.pane_done if w else 0) + ) + pane_remaining = max(0, pane_tot - pane_done_cur) + pane_progress_rel = f"{pane_done_cur}/{pane_tot}" if pane_tot else "" + + spt = self.session_pane_total or 0 + session_pane_progress = f"{self.session_panes_done}/{spt}" if spt else "" + overall_percent = int(self.session_panes_done / spt * 100) if spt else 0 + + summary_parts: list[str] = [] + if self.windows_done: + summary_parts.append(f"{self.windows_done} win") + if self.session_panes_done: + summary_parts.append(f"{self.session_panes_done} panes") + summary = f"[{', '.join(summary_parts)}]" if summary_parts else "" + + return { + "workspace_path": self.workspace_path, + "session": self.session_name or "", + "window": w.name if w else "", + "window_index": window_idx, + "window_total": win_tot, + "window_progress": win_progress, + "pane_index": pane_idx, + "pane_total": pane_tot, + "pane_progress": pane_progress, + "progress": progress, + "windows_done": win_done, + "windows_remaining": max(0, win_tot - win_done), + "window_progress_rel": win_progress_rel, + "pane_done": pane_done_cur, + "pane_remaining": pane_remaining, + "pane_progress_rel": pane_progress_rel, + "session_pane_total": spt, + "session_panes_done": self.session_panes_done, + "session_panes_remaining": max(0, spt - self.session_panes_done), + "session_pane_progress": session_pane_progress, + "overall_percent": overall_percent, + "summary": summary, + } + + def format_template( + self, + fmt: str, + extra: dict[str, t.Any] | None = None, + ) -> str: + """Render *fmt* with the current build state. + + Returns ``""`` before ``session_created`` fires so callers can + fall back to a pre-build message. Unknown ``{tokens}`` are left + as-is (not dropped silently). + + The optional *extra* dict is merged on top of :meth:`_context` so + callers (e.g. :class:`Spinner`) can inject ANSI-colored tokens like + ``{bar}`` without adding color concerns to :class:`BuildTree`. + + Examples + -------- + >>> tree = BuildTree() + >>> tree.format_template("{session} [{progress}] {window}") + '' + >>> ev = {"event": "session_created", "name": "cihai", "window_total": 3} + >>> tree.on_event(ev) + >>> tree.format_template("{session} [{progress}] {window}") + 'cihai [] ' + >>> ev = {"event": "window_started", "name": "editor", "pane_total": 4} + >>> tree.on_event(ev) + >>> tree.format_template("{session} [{progress}] {window}") + 'cihai [1/3 win] editor' + >>> tree.on_event({"event": "pane_creating", "pane_num": 2, "pane_total": 4}) + >>> tree.format_template("{session} [{progress}] {window}") + 'cihai [1/3 win · 2/4 pane] editor' + >>> tree.format_template("minimal: {session} [{window_progress}]") + 'minimal: cihai [1/3]' + >>> tree.format_template("{session} {unknown_token}") + 'cihai {unknown_token}' + >>> tree.format_template("{session}", extra={"custom": "value"}) + 'cihai' + """ + if self.session_name is None: + return "" + ctx: dict[str, t.Any] = self._context() + if extra: + ctx = {**ctx, **extra} + return fmt.format_map(_SafeFormatMap(ctx)) + + def format_inline(self, base: str) -> str: + """Return base message with current build state appended inline. + + Parameters + ---------- + base : str + The original spinner message to start from. + + Returns + ------- + str + ``base`` alone if no session has been created yet; otherwise + ``"base session_name [W of N windows, P of M panes] window_name"``, + omitting the bracket section when there is no current window, and + omitting individual parts when their totals are not known. + """ + if self.session_name is None: + return base + parts = [base, self.session_name] + if self.windows: + w = self.windows[-1] + window_idx = len(self.windows) + bracket_parts: list[str] = [] + if self.window_total is not None: + bracket_parts.append(f"{window_idx} of {self.window_total} windows") + if w.pane_num is not None and w.pane_total is not None: + bracket_parts.append(f"{w.pane_num} of {w.pane_total} panes") + if bracket_parts: + parts.append(f"[{', '.join(bracket_parts)}]") + parts.append(w.name) + return " ".join(parts) + + +class Spinner: + """A threaded spinner for CLI progress. + + Examples + -------- + >>> import io + >>> stream = io.StringIO() + >>> with Spinner("Build...", color_mode=ColorMode.NEVER, stream=stream) as spinner: + ... spinner.add_output_line("Session created: test") + ... spinner.update_message("Creating window: editor") + """ + + def __init__( + self, + message: str = "Loading...", + color_mode: ColorMode = ColorMode.AUTO, + stream: t.TextIO = sys.stderr, + interval: float = 0.1, + output_lines: int = DEFAULT_OUTPUT_LINES, + progress_format: str | None = None, + workspace_path: str = "", + ) -> None: + """Initialize spinner. + + Parameters + ---------- + message : str + Text displayed next to the spinner animation. + color_mode : ColorMode + ANSI color mode for styled output. + stream : t.TextIO + Output stream (default ``sys.stderr``). + interval : float + Seconds between animation frames. + output_lines : int + Max lines in the scrolling output panel. ``0`` hides the panel, + ``-1`` means unlimited. + progress_format : str | None + Format string for progress output. Tokens are documented in + :class:`BuildTree`. ``None`` uses the built-in default. + workspace_path : str + Absolute path to the workspace config file, shown in success + output. + """ + self.message = message + self._base_message = message + self.colors = Colors(color_mode) + self.stream = stream + self.interval = interval + + self._stop_event = threading.Event() + self._thread: threading.Thread | None = None + self._enabled = self._should_enable() + self._panel_hidden = output_lines == 0 + if output_lines < 0: + self._output_lines: collections.deque[str] = ( + collections.deque() + ) # unlimited + elif output_lines == 0: + self._output_lines = collections.deque(maxlen=1) # drop, never render + else: + self._output_lines = collections.deque(maxlen=output_lines) + self._prev_height: int = 0 + self._build_tree: BuildTree = BuildTree(workspace_path=workspace_path) + self._progress_format: str | None = ( + resolve_progress_format(progress_format) + if progress_format is not None + else None + ) + + def _should_enable(self) -> bool: + """Check if spinner should be enabled (TTY check).""" + return self.stream.isatty() + + def _restore_cursor(self) -> None: + """Unconditionally restore cursor — called by atexit on abnormal exit.""" + self.stream.write(SHOW_CURSOR) + self.stream.flush() + + def _spin(self) -> None: + """Spin in background thread.""" + frames = itertools.cycle(SPINNER_FRAMES) + march_pos = 0 # marching bar position counter (local to _spin) + + self.stream.write(HIDE_CURSOR) + self.stream.flush() + + try: + while not self._stop_event.is_set(): + frame = next(frames) + term_width = shutil.get_terminal_size(fallback=(80, 24)).columns + if self._panel_hidden: + panel: list[str] = [] + else: + term_height = shutil.get_terminal_size( + fallback=(80, 24), + ).lines + raw_panel = list(self._output_lines) + max_panel = term_height - 2 + if len(raw_panel) > max_panel: + raw_panel = raw_panel[-max_panel:] + panel = [ + _truncate_visible(line, term_width - 1, suffix="") + for line in raw_panel + ] + new_height = len(panel) + 1 # panel lines + spinner line + + parts: list[str] = [] + + # Erase previous render (cursor is at end of previous spinner line) + if self._prev_height > 0: + parts.append(f"{CURSOR_TO_COL0}{ERASE_LINE}") + parts.extend( + f"{CURSOR_UP_1}{ERASE_LINE}" + for _ in range(self._prev_height - 1) + ) + + # Write panel lines (tree lines already constrained by render()) + parts.extend(f"{output_line}\n" for output_line in panel) + + # Determine final spinner message + if ( + self._progress_format is not None + and self._build_tree._before_script_event.is_set() + ): + # Marching bar: sweep a 2-cell highlight across the bar + p = march_pos % max(1, BAR_WIDTH - 1) + march_bar = ( + self.colors.muted("░" * p) + + self.colors.warning("░░") + + self.colors.muted("░" * max(0, BAR_WIDTH - p - 2)) + ) + tree = self._build_tree + extra: dict[str, t.Any] = { + "session": self.colors.highlight( + tree.session_name or "", + ), + "bar": march_bar, + "pane_bar": march_bar, + "window_bar": march_bar, + "status_icon": self.colors.warning("⏸"), + } + rendered = self._build_tree.format_template( + self._progress_format, extra=extra + ) + msg = rendered if rendered else self._base_message + march_pos += 1 + else: + msg = self.message + march_pos = 0 # reset when not in before_script + + # Write spinner line (no trailing newline — cursor stays here) + spinner_text = f"{self.colors.info(frame)} {msg}" + if _visible_len(spinner_text) > term_width - 1: + spinner_text = _truncate_visible(spinner_text, term_width - 4) + parts.append(f"{CURSOR_TO_COL0}{spinner_text}") + + # Wrap entire frame in synchronized output to prevent flicker. + # Terminals that don't support it safely ignore the sequences. + self.stream.write(SYNC_START + "".join(parts) + SYNC_END) + self.stream.flush() + self._prev_height = new_height + time.sleep(self.interval) + finally: + # Erase the whole block and show cursor + if self._prev_height > 0: + self.stream.write(f"{CURSOR_TO_COL0}{ERASE_LINE}") + for _ in range(self._prev_height - 1): + self.stream.write(f"{CURSOR_UP_1}{ERASE_LINE}") + self.stream.write(SHOW_CURSOR) + self.stream.flush() + self._prev_height = 0 + + def add_output_line(self, line: str) -> None: + r"""Append a line to the live output panel (thread-safe via GIL). + + When the spinner is disabled (non-TTY), writes directly to the stream + so output is not silently swallowed. + + Examples + -------- + >>> import io + >>> stream = io.StringIO() + >>> spinner = Spinner("test", color_mode=ColorMode.NEVER, stream=stream) + >>> spinner.add_output_line("hello world") + >>> stream.getvalue() + 'hello world\n' + """ + stripped = line.rstrip("\n\r") + if stripped: + if self._enabled: + self._output_lines.append(stripped) + else: + self.stream.write(stripped + "\n") + self.stream.flush() + + def update_message(self, message: str) -> None: + """Update the message displayed next to the spinner. + + Examples + -------- + >>> import io + >>> stream = io.StringIO() + >>> spinner = Spinner("initial", color_mode=ColorMode.NEVER, stream=stream) + >>> spinner.message + 'initial' + >>> spinner.update_message("updated") + >>> spinner.message + 'updated' + """ + self.message = message + + def _build_extra(self) -> dict[str, t.Any]: + """Return spinner-owned template tokens (colored bar, status_icon). + + These are separated from :meth:`BuildTree._context` to keep ANSI/color + concerns out of :class:`BuildTree`, which is also used in tests without + colors. + + Examples + -------- + >>> import io + >>> stream = io.StringIO() + >>> spinner = Spinner("x", color_mode=ColorMode.NEVER, stream=stream) + >>> spinner._build_tree.on_event( + ... { + ... "event": "session_created", + ... "name": "s", + ... "window_total": 4, + ... "session_pane_total": 8, + ... } + ... ) + >>> extra = spinner._build_extra() + >>> extra["bar"] + '░░░░░░░░░░' + >>> extra["status_icon"] + '' + """ + tree = self._build_tree + win_tot = tree.window_total or 0 + spt = tree.session_pane_total or 0 + + # Composite fraction: (windows_done + pane_frac) / window_total + if win_tot > 0: + cw = tree.windows[-1] if tree.windows else None + pane_frac = 0.0 + if cw and not cw.done and cw.pane_total: + pane_frac = (cw.pane_num or 0) / cw.pane_total + composite_done = tree.windows_done + pane_frac + composite_bar = render_bar(int(composite_done * 100), win_tot * 100) + else: + composite_bar = render_bar(0, 0) + + pane_bar = render_bar(tree.session_panes_done, spt) + window_bar = render_bar(tree.windows_done, win_tot) + + def _color_bar(plain: str) -> str: + if not plain: + return plain + filled = plain.count("█") + empty = plain.count("░") + return self.colors.success("█" * filled) + self.colors.muted("░" * empty) + + return { + "session": self.colors.highlight(tree.session_name or ""), + "bar": _color_bar(composite_bar), + "pane_bar": _color_bar(pane_bar), + "window_bar": _color_bar(window_bar), + "status_icon": "", + } + + def on_build_event(self, event: dict[str, t.Any]) -> None: + """Forward build event to BuildTree and update spinner message inline. + + Examples + -------- + >>> import io + >>> stream = io.StringIO() + >>> spinner = Spinner("Loading", color_mode=ColorMode.NEVER, stream=stream) + >>> spinner.on_build_event({ + ... "event": "session_created", "name": "myapp", + ... "window_total": 2, "session_pane_total": 3, + ... }) + >>> spinner._build_tree.session_name + 'myapp' + """ + self._build_tree.on_event(event) + if self._progress_format is not None: + extra = self._build_extra() + rendered = self._build_tree.format_template( + self._progress_format, extra=extra + ) + # Only switch to template output once a window has started so that + # the session_created → window_started gap doesn't show empty brackets. + self.message = ( + rendered + if (rendered and self._build_tree.windows) + else self._base_message + ) + else: + self.message = self._build_tree.format_inline(self._base_message) + + def start(self) -> None: + """Start the spinner thread. + + Examples + -------- + >>> import io + >>> stream = io.StringIO() + >>> spinner = Spinner("test", color_mode=ColorMode.NEVER, stream=stream) + >>> spinner.start() + >>> spinner.stop() + """ + if not self._enabled: + return + + atexit.register(self._restore_cursor) + self._stop_event.clear() + self._thread = threading.Thread(target=self._spin, daemon=True) + self._thread.start() + + def stop(self) -> None: + """Stop the spinner thread. + + Examples + -------- + >>> import io + >>> stream = io.StringIO() + >>> spinner = Spinner("test", color_mode=ColorMode.NEVER, stream=stream) + >>> spinner.start() + >>> spinner.stop() + >>> spinner._thread is None + True + """ + if self._thread and self._thread.is_alive(): + self._stop_event.set() + self._thread.join() + self._thread = None + atexit.unregister(self._restore_cursor) + + def format_success(self) -> str: + """Render the success template with current build state. + + Uses :data:`SUCCESS_TEMPLATE` with colored ``{session}`` + (``highlight()``), ``{workspace_path}`` (``info()``), and + ``{summary}`` (``muted()``) from :meth:`BuildTree._context`. + + Examples + -------- + >>> import io + >>> stream = io.StringIO() + >>> spinner = Spinner("x", color_mode=ColorMode.NEVER, stream=stream, + ... workspace_path="~/.tmuxp/myapp.yaml") + >>> spinner._build_tree.on_event({ + ... "event": "session_created", "name": "myapp", + ... "window_total": 2, "session_pane_total": 4, + ... }) + >>> spinner._build_tree.on_event( + ... {"event": "window_started", "name": "w1", "pane_total": 2}) + >>> spinner._build_tree.on_event({"event": "window_done"}) + >>> spinner._build_tree.on_event( + ... {"event": "window_started", "name": "w2", "pane_total": 2}) + >>> spinner._build_tree.on_event({"event": "window_done"}) + >>> spinner.format_success() + 'Loaded workspace: myapp (~/.tmuxp/myapp.yaml) [2 win, 4 panes]' + """ + tree = self._build_tree + ctx = tree._context() + extra: dict[str, t.Any] = { + "session": self.colors.highlight(tree.session_name or ""), + "workspace_path": self.colors.info(ctx.get("workspace_path", "")), + "summary": self.colors.muted(ctx.get("summary", "")) + if ctx.get("summary") + else "", + } + return SUCCESS_TEMPLATE.format_map(_SafeFormatMap({**ctx, **extra})) + + def success(self, text: str | None = None) -> None: + """Stop the spinner and print a success line. + + Parameters + ---------- + text : str | None + The success message to display after the checkmark. + When ``None``, uses :meth:`format_success` if a progress format + is configured, otherwise falls back to ``_base_message``. + + Examples + -------- + >>> import io + >>> stream = io.StringIO() + >>> spinner = Spinner("x", color_mode=ColorMode.NEVER, stream=stream) + >>> spinner.success("done") + >>> "✓ done" in stream.getvalue() + True + + With no args and no progress format, falls back to base message: + + >>> stream2 = io.StringIO() + >>> spinner2 = Spinner("Loading...", color_mode=ColorMode.NEVER, stream=stream2) + >>> spinner2.success() + >>> "✓ Loading..." in stream2.getvalue() + True + """ + self.stop() + if text is None and self._progress_format is not None: + text = self.format_success() + elif text is None: + text = self._base_message + checkmark = self.colors.success("\u2713") + msg = f"{checkmark} {text}" + self.stream.write(f"{msg}\n") + self.stream.flush() + + def __enter__(self) -> Spinner: + self.start() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: types.TracebackType | None, + ) -> t.Literal[False]: + self.stop() + return False From 12e5f12bee8a14a1974c9ed166b35c45e958f6f6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 14:49:43 -0500 Subject: [PATCH 22/50] feat(load[spinner]): wire progress spinner into tmuxp load command why: Users need visual feedback during workspace builds, especially for sessions with many windows or long before_script executions. what: - Add _silence_stream_handlers() to suppress StreamHandler during spinner - Add _dispatch_build() extracting shared build/attach/error logic - Wire Spinner.on_build_event and add_output_line to builder callbacks - Add --progress-format / TMUXP_PROGRESS_FORMAT for preset or custom format - Add --progress-lines / TMUXP_PROGRESS_LINES for panel height control - Add --no-progress / TMUXP_PROGRESS=0 to disable spinner entirely - Emit persistent success line with checkmark after successful build - Stop spinner before interactive prompts (TMUX switch, error recovery) --- src/tmuxp/cli/load.py | 380 +++++++++++++++++++++++++++++++++++------- 1 file changed, 318 insertions(+), 62 deletions(-) diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index 4673c4cde6..375cdb1b22 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import contextlib import importlib import logging import os @@ -21,10 +22,41 @@ from tmuxp.workspace.finders import find_workspace_file, get_workspace_dir from ._colors import ColorMode, Colors, build_description, get_color_mode +from ._progress import ( + DEFAULT_OUTPUT_LINES, + SUCCESS_TEMPLATE, + Spinner, + _SafeFormatMap, + resolve_progress_format, +) from .utils import prompt_choices, prompt_yes_no, tmuxp_echo logger = logging.getLogger(__name__) + +@contextlib.contextmanager +def _silence_stream_handlers(logger_name: str = "tmuxp") -> t.Iterator[None]: + """Temporarily raise StreamHandler level to WARNING while spinner is active. + + INFO/DEBUG log records are diagnostics for aggregators, not user-facing output; + the spinner is the user-facing progress channel. Restores original levels on exit. + """ + _log = logging.getLogger(logger_name) + saved: list[tuple[logging.StreamHandler[t.Any], int]] = [ + (h, h.level) + for h in _log.handlers + if isinstance(h, logging.StreamHandler) + and not isinstance(h, logging.FileHandler) + ] + for h, _ in saved: + h.setLevel(logging.WARNING) + try: + yield + finally: + for h, level in saved: + h.setLevel(level) + + LOAD_DESCRIPTION = build_description( """ Load tmuxp workspace file(s) and create or attach to a tmux session. @@ -77,6 +109,9 @@ class CLILoadNamespace(argparse.Namespace): color: CLIColorModeLiteral log_file: str | None log_level: str + progress_format: str | None + panel_lines: int | None + no_progress: bool def load_plugins( @@ -196,7 +231,11 @@ def _reattach(builder: WorkspaceBuilder, colors: Colors | None = None) -> None: builder.session.attach() -def _load_attached(builder: WorkspaceBuilder, detached: bool) -> None: +def _load_attached( + builder: WorkspaceBuilder, + detached: bool, + pre_attach_hook: t.Callable[[], None] | None = None, +) -> None: """ Load workspace in new session. @@ -204,10 +243,16 @@ def _load_attached(builder: WorkspaceBuilder, detached: bool) -> None: ---------- builder: :class:`workspace.builder.WorkspaceBuilder` detached : bool + pre_attach_hook : callable, optional + called after build, before attach/switch_client; use to stop the spinner + so its cleanup sequences don't appear inside the tmux pane. """ builder.build() assert builder.session is not None + if pre_attach_hook is not None: + pre_attach_hook() + if "TMUX" in os.environ: # tmuxp ran from inside tmux # unset TMUX, save it, e.g. '/tmp/tmux-1000/default,30668,0' tmux_env = os.environ.pop("TMUX") @@ -219,7 +264,11 @@ def _load_attached(builder: WorkspaceBuilder, detached: bool) -> None: builder.session.attach() -def _load_detached(builder: WorkspaceBuilder, colors: Colors | None = None) -> None: +def _load_detached( + builder: WorkspaceBuilder, + colors: Colors | None = None, + pre_output_hook: t.Callable[[], None] | None = None, +) -> None: """ Load workspace in new session but don't attach. @@ -228,11 +277,16 @@ def _load_detached(builder: WorkspaceBuilder, colors: Colors | None = None) -> N builder: :class:`workspace.builder.WorkspaceBuilder` colors : Colors | None Optional Colors instance for styled output. + pre_output_hook : Callable | None + Called after build but before printing, e.g. to stop a spinner. """ builder.build() assert builder.session is not None + if pre_output_hook is not None: + pre_output_hook() + msg = "Session created in detached state." tmuxp_echo(colors.info(msg) if colors else msg) logger.info("session created in detached state") @@ -265,6 +319,123 @@ def _setup_plugins(builder: WorkspaceBuilder) -> Session: return builder.session +def _dispatch_build( + builder: WorkspaceBuilder, + detached: bool, + append: bool, + answer_yes: bool, + cli_colors: Colors, + pre_attach_hook: t.Callable[[], None] | None = None, + on_error_hook: t.Callable[[], None] | None = None, + pre_prompt_hook: t.Callable[[], None] | None = None, +) -> Session | None: + """Dispatch the build to the correct load path and handle errors. + + Handles the detached/attached/append switching logic and the + ``TmuxpException`` error-recovery prompt. Extracted so the + spinner-enabled and spinner-disabled paths share one implementation. + + Parameters + ---------- + builder : WorkspaceBuilder + Configured workspace builder. + detached : bool + Load session in detached state. + append : bool + Append windows to the current session. + answer_yes : bool + Skip interactive prompts. + cli_colors : Colors + Colors instance for styled output. + pre_attach_hook : callable, optional + Called before attach/switch_client (e.g. stop spinner). + on_error_hook : callable, optional + Called before showing the error-recovery prompt (e.g. stop spinner). + pre_prompt_hook : callable, optional + Called before any interactive prompt (e.g. stop spinner so ANSI + escape sequences don't garble the terminal during user input). + + Returns + ------- + Session | None + The built session, or ``None`` if the user killed it on error. + + Examples + -------- + >>> from tmuxp.cli.load import _dispatch_build + >>> callable(_dispatch_build) + True + """ + try: + if detached: + _load_detached(builder, cli_colors, pre_output_hook=pre_attach_hook) + return _setup_plugins(builder) + + if append: + if "TMUX" in os.environ: # tmuxp ran from inside tmux + _load_append_windows_to_current_session(builder) + else: + _load_attached(builder, detached, pre_attach_hook=pre_attach_hook) + + return _setup_plugins(builder) + + # append and answer_yes have no meaning if specified together + if answer_yes: + _load_attached(builder, detached, pre_attach_hook=pre_attach_hook) + return _setup_plugins(builder) + + if "TMUX" in os.environ: # tmuxp ran from inside tmux + if pre_prompt_hook is not None: + pre_prompt_hook() + msg = ( + "Already inside TMUX, switch to session? yes/no\n" + "Or (a)ppend windows in the current active session?\n[y/n/a]" + ) + options = ["y", "n", "a"] + choice = prompt_choices(msg, choices=options, color_mode=cli_colors.mode) + + if choice == "y": + _load_attached(builder, detached, pre_attach_hook=pre_attach_hook) + elif choice == "a": + _load_append_windows_to_current_session(builder) + else: + _load_detached(builder, cli_colors) + else: + _load_attached(builder, detached, pre_attach_hook=pre_attach_hook) + + except exc.TmuxpException as e: + if on_error_hook is not None: + on_error_hook() + logger.debug("workspace build failed", exc_info=True) + tmuxp_echo(cli_colors.error("[Error]") + f" {e}") + + choice = prompt_choices( + cli_colors.error("Error loading workspace.") + + " (k)ill, (a)ttach, (d)etach?", + choices=["k", "a", "d"], + default="k", + color_mode=cli_colors.mode, + ) + + if choice == "k": + if builder.session is not None: + builder.session.kill() + tmuxp_echo(cli_colors.muted("Session killed.")) + logger.info("session killed by user after build error") + elif choice == "a": + _reattach(builder, cli_colors) + else: + sys.exit() + return None + finally: + builder.on_progress = None + builder.on_before_script = None + builder.on_script_output = None + builder.on_build_event = None + + return _setup_plugins(builder) + + def load_workspace( workspace_file: StrPath, socket_name: str | None = None, @@ -276,6 +447,9 @@ def load_workspace( answer_yes: bool = False, append: bool = False, cli_colors: Colors | None = None, + progress_format: str | None = None, + panel_lines: int | None = None, + no_progress: bool = False, ) -> Session | None: """Entrypoint for ``tmuxp load``, load a tmuxp "workspace" session via config file. @@ -301,6 +475,15 @@ def load_workspace( Default False. cli_colors : Colors, optional Colors instance for CLI output formatting. If None, uses AUTO mode. + progress_format : str, optional + Spinner format preset name or custom format string with tokens. + panel_lines : int, optional + Number of script-output lines shown in the spinner panel. + Defaults to the :class:`~tmuxp.cli._progress.Spinner` default (3). + Override via ``TMUXP_PROGRESS_LINES`` environment variable. + no_progress : bool + Disable the progress spinner entirely. Default False. + Also disabled when ``TMUXP_PROGRESS=0``. Notes ----- @@ -361,11 +544,13 @@ def load_workspace( "loading workspace", extra={"tmux_config_path": str(workspace_file)}, ) - tmuxp_echo( - cli_colors.info("[Loading]") - + " " - + cli_colors.highlight(str(PrivatePath(workspace_file))), - ) + _progress_disabled = no_progress or os.getenv("TMUXP_PROGRESS", "1") == "0" + if _progress_disabled: + tmuxp_echo( + cli_colors.info("[Loading]") + + " " + + cli_colors.highlight(str(PrivatePath(workspace_file))), + ) # ConfigReader allows us to open a yaml or json file as a dict raw_workspace = config_reader.ConfigReader._from_file(workspace_file) or {} @@ -425,64 +610,96 @@ def load_workspace( _reattach(builder, cli_colors) return None - try: - if detached: - _load_detached(builder, cli_colors) - return _setup_plugins(builder) - - if append: - if "TMUX" in os.environ: # tmuxp ran from inside tmux - _load_append_windows_to_current_session(builder) - else: - _load_attached(builder, detached) - - return _setup_plugins(builder) - - # append and answer_yes have no meaning if specified together - if answer_yes: - _load_attached(builder, detached) - return _setup_plugins(builder) - - if "TMUX" in os.environ: # tmuxp ran from inside tmux - msg = ( - "Already inside TMUX, switch to session? yes/no\n" - "Or (a)ppend windows in the current active session?\n[y/n/a]" + if _progress_disabled: + _private_path = str(PrivatePath(workspace_file)) + result = _dispatch_build( + builder, + detached, + append, + answer_yes, + cli_colors, + ) + if result is not None: + summary = "" + try: + win_count = len(result.windows) + pane_count = sum(len(w.panes) for w in result.windows) + summary_parts: list[str] = [] + if win_count: + summary_parts.append(f"{win_count} win") + if pane_count: + summary_parts.append(f"{pane_count} panes") + summary = f"[{', '.join(summary_parts)}]" if summary_parts else "" + except Exception: + logger.debug("session gone before summary", exc_info=True) + ctx = { + "session": cli_colors.highlight(session_name), + "workspace_path": cli_colors.info(_private_path), + "summary": cli_colors.muted(summary) if summary else "", + } + checkmark = cli_colors.success("\u2713") + tmuxp_echo( + f"{checkmark} {SUCCESS_TEMPLATE.format_map(_SafeFormatMap(ctx))}" ) - options = ["y", "n", "a"] - choice = prompt_choices(msg, choices=options, color_mode=cli_colors.mode) + return result - if choice == "y": - _load_attached(builder, detached) - elif choice == "a": - _load_append_windows_to_current_session(builder) - else: - _load_detached(builder, cli_colors) - else: - _load_attached(builder, detached) - - except exc.TmuxpException as e: - logger.debug("workspace build failed", exc_info=True) - tmuxp_echo(cli_colors.error("[Error]") + f" {e}") - - choice = prompt_choices( - cli_colors.error("Error loading workspace.") - + " (k)ill, (a)ttach, (d)etach?", - choices=["k", "a", "d"], - default="k", - color_mode=cli_colors.mode, + # Spinner wraps only the actual build phase + _progress_fmt = resolve_progress_format( + progress_format + if progress_format is not None + else os.getenv("TMUXP_PROGRESS_FORMAT", "default") + ) + _panel_lines_env = os.getenv("TMUXP_PROGRESS_LINES") + if _panel_lines_env: + try: + _panel_lines_env_int: int | None = int(_panel_lines_env) + except ValueError: + _panel_lines_env_int = None + else: + _panel_lines_env_int = None + _panel_lines = panel_lines if panel_lines is not None else _panel_lines_env_int + _private_path = str(PrivatePath(workspace_file)) + _spinner = Spinner( + message=( + f"Loading workspace: {cli_colors.highlight(session_name)} ({_private_path})" + ), + color_mode=cli_colors.mode, + progress_format=_progress_fmt, + output_lines=_panel_lines if _panel_lines is not None else DEFAULT_OUTPUT_LINES, + workspace_path=_private_path, + ) + _success_emitted = False + + def _emit_success() -> None: + nonlocal _success_emitted + if _success_emitted: + return + _success_emitted = True + _spinner.success() + + with ( + _silence_stream_handlers(), + _spinner as spinner, + ): + builder.on_build_event = spinner.on_build_event + _resolved_panel = ( + _panel_lines if _panel_lines is not None else DEFAULT_OUTPUT_LINES ) - - if choice == "k": - if builder.session is not None: - builder.session.kill() - tmuxp_echo(cli_colors.muted("Session killed.")) - logger.info("session killed by user after build error") - elif choice == "a": - _reattach(builder, cli_colors) - else: - sys.exit() - - return _setup_plugins(builder) + if _resolved_panel != 0: + builder.on_script_output = spinner.add_output_line + result = _dispatch_build( + builder, + detached, + append, + answer_yes, + cli_colors, + pre_attach_hook=_emit_success, + on_error_hook=spinner.stop, + pre_prompt_hook=spinner.stop, + ) + if result is not None: + _emit_success() + return result def create_load_subparser(parser: argparse.ArgumentParser) -> argparse.ArgumentParser: @@ -567,6 +784,42 @@ def create_load_subparser(parser: argparse.ArgumentParser) -> argparse.ArgumentP help="file to log errors/output to", ) + parser.add_argument( + "--progress-format", + metavar="FORMAT", + dest="progress_format", + default=None, + help=( + "Spinner line format: preset name " + "(default, minimal, window, pane, verbose) " + "or a format string with tokens " + "{session}, {window}, {progress}, {window_progress}, {pane_progress}, etc. " + "Env: TMUXP_PROGRESS_FORMAT" + ), + ) + + parser.add_argument( + "--progress-lines", + metavar="N", + dest="panel_lines", + type=int, + default=None, + help=( + "Number of script-output lines shown in the spinner panel (default: 3). " + "0 hides the panel entirely (script output goes to stdout). " + "-1 shows unlimited lines (capped to terminal height). " + "Env: TMUXP_PROGRESS_LINES" + ), + ) + + parser.add_argument( + "--no-progress", + dest="no_progress", + action="store_true", + default=False, + help=("Disable the animated progress spinner. Env: TMUXP_PROGRESS=0"), + ) + try: import shtab @@ -648,4 +901,7 @@ def command_load( answer_yes=args.answer_yes or False, append=args.append or False, cli_colors=cli_colors, + progress_format=args.progress_format, + panel_lines=args.panel_lines, + no_progress=args.no_progress, ) From 2528c5908deeb8f14133f9d4227333b7733c8437 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 14:51:55 -0500 Subject: [PATCH 23/50] test(progress[coverage]): add comprehensive tests for spinner and build progress why: The progress spinner and its CLI wiring need thorough test coverage for BuildTree state, template rendering, bar generation, panel behavior, and CLI flag handling. what: - Add tests/cli/test_progress.py covering Spinner lifecycle, BuildTree state transitions, template presets, bar rendering, panel lines, ANSI truncation, non-TTY fallback, and success output - Add tests/workspace/test_progress.py for builder callback integration - Update tests/cli/test_load.py for progress output assertions --- tests/cli/test_load.py | 80 ++ tests/cli/test_progress.py | 1425 ++++++++++++++++++++++++++++++ tests/workspace/test_progress.py | 258 ++++++ 3 files changed, 1763 insertions(+) create mode 100644 tests/cli/test_progress.py create mode 100644 tests/workspace/test_progress.py diff --git a/tests/cli/test_load.py b/tests/cli/test_load.py index 60c2c446ed..ec045dcf3c 100644 --- a/tests/cli/test_load.py +++ b/tests/cli/test_load.py @@ -807,6 +807,86 @@ def test_load_no_ansi_in_nontty_stderr( assert "\x1b[" not in captured.err, "ANSI codes leaked into non-TTY stderr" +class ProgressDisableFixture(t.NamedTuple): + """Test fixture for progress disable logic.""" + + test_id: str + env_value: str | None + no_progress_flag: bool + expected_disabled: bool + + +PROGRESS_DISABLE_FIXTURES: list[ProgressDisableFixture] = [ + ProgressDisableFixture("default_enabled", None, False, False), + ProgressDisableFixture("env_disabled", "0", False, True), + ProgressDisableFixture("flag_disabled", None, True, True), + ProgressDisableFixture("env_enabled_explicit", "1", False, False), + ProgressDisableFixture("flag_overrides_env", "1", True, True), +] + + +@pytest.mark.parametrize( + list(ProgressDisableFixture._fields), + PROGRESS_DISABLE_FIXTURES, + ids=[f.test_id for f in PROGRESS_DISABLE_FIXTURES], +) +def test_progress_disable_logic( + test_id: str, + env_value: str | None, + no_progress_flag: bool, + expected_disabled: bool, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Progress disable expression matches expected behavior.""" + if env_value is not None: + monkeypatch.setenv("TMUXP_PROGRESS", env_value) + else: + monkeypatch.delenv("TMUXP_PROGRESS", raising=False) + + import os + + result = no_progress_flag or os.getenv("TMUXP_PROGRESS", "1") == "0" + assert result is expected_disabled + + +def test_load_workspace_no_progress( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """load_workspace with no_progress=True creates session without spinner.""" + monkeypatch.delenv("TMUX", raising=False) + session_file = FIXTURE_PATH / "workspace/builder" / "two_pane.yaml" + + session = load_workspace( + session_file, + socket_name=server.socket_name, + detached=True, + no_progress=True, + ) + + assert isinstance(session, Session) + assert session.name == "sample workspace" + + +def test_load_workspace_env_progress_disabled( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """load_workspace with TMUXP_PROGRESS=0 creates session without spinner.""" + monkeypatch.delenv("TMUX", raising=False) + monkeypatch.setenv("TMUXP_PROGRESS", "0") + session_file = FIXTURE_PATH / "workspace/builder" / "two_pane.yaml" + + session = load_workspace( + session_file, + socket_name=server.socket_name, + detached=True, + ) + + assert isinstance(session, Session) + assert session.name == "sample workspace" + + def test_load_masks_home_in_spinner_message(monkeypatch: pytest.MonkeyPatch) -> None: """Spinner message should mask home directory via PrivatePath.""" monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) diff --git a/tests/cli/test_progress.py b/tests/cli/test_progress.py new file mode 100644 index 0000000000..8ace187b65 --- /dev/null +++ b/tests/cli/test_progress.py @@ -0,0 +1,1425 @@ +"""Tests for tmuxp CLI progress indicator.""" + +from __future__ import annotations + +import atexit +import io +import pathlib +import time +import typing as t + +import libtmux +import pytest + +from tmuxp.cli._colors import ColorMode +from tmuxp.cli._progress import ( + BAR_WIDTH, + ERASE_LINE, + HIDE_CURSOR, + PROGRESS_PRESETS, + SHOW_CURSOR, + SUCCESS_TEMPLATE, + BuildTree, + Spinner, + _truncate_visible, + _visible_len, + render_bar, + resolve_progress_format, +) + + +class SpinnerEnablementFixture(t.NamedTuple): + """Test fixture for spinner TTY/color enablement matrix.""" + + test_id: str + isatty: bool + color_mode: ColorMode + expected_enabled: bool + + +SPINNER_ENABLEMENT_FIXTURES: list[SpinnerEnablementFixture] = [ + SpinnerEnablementFixture("tty_color_always", True, ColorMode.ALWAYS, True), + SpinnerEnablementFixture("tty_color_auto", True, ColorMode.AUTO, True), + SpinnerEnablementFixture("tty_color_never", True, ColorMode.NEVER, True), + SpinnerEnablementFixture("non_tty_color_always", False, ColorMode.ALWAYS, False), + SpinnerEnablementFixture("non_tty_color_never", False, ColorMode.NEVER, False), +] + + +@pytest.mark.parametrize( + list(SpinnerEnablementFixture._fields), + SPINNER_ENABLEMENT_FIXTURES, + ids=[f.test_id for f in SPINNER_ENABLEMENT_FIXTURES], +) +def test_spinner_enablement( + test_id: str, + isatty: bool, + color_mode: ColorMode, + expected_enabled: bool, +) -> None: + """Spinner._enabled depends only on TTY, not on color mode.""" + stream = io.StringIO() + stream.isatty = lambda: isatty # type: ignore[method-assign] + + spinner = Spinner(message="Test", color_mode=color_mode, stream=stream) + assert spinner._enabled is expected_enabled + + +def test_spinner_disabled_output() -> None: + """Disabled spinner produces no output.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + with Spinner(message="Test", stream=stream) as spinner: + spinner.update_message("Updated") + + assert stream.getvalue() == "" + + +def test_spinner_enabled_output() -> None: + """Enabled spinner writes ANSI control sequences.""" + stream = io.StringIO() + stream.isatty = lambda: True # type: ignore[method-assign] + + with Spinner( + message="Test", color_mode=ColorMode.ALWAYS, stream=stream, interval=0.01 + ): + pass # enter and exit — enough for at least one frame + cleanup + + output = stream.getvalue() + assert HIDE_CURSOR in output + assert SHOW_CURSOR in output + assert ERASE_LINE in output + assert "Test" in output + + +def test_spinner_atexit_registered(monkeypatch: pytest.MonkeyPatch) -> None: + """atexit.register called on start, unregistered on stop.""" + registered: list[t.Any] = [] + unregistered: list[t.Any] = [] + monkeypatch.setattr(atexit, "register", lambda fn, *a: registered.append(fn)) + monkeypatch.setattr(atexit, "unregister", lambda fn: unregistered.append(fn)) + + stream = io.StringIO() + stream.isatty = lambda: True # type: ignore[method-assign] + + with Spinner(message="Test", color_mode=ColorMode.ALWAYS, stream=stream) as spinner: + assert len(registered) == 1 + assert spinner._restore_cursor in registered + + assert len(unregistered) == 1 + assert spinner._restore_cursor in unregistered + + +def test_spinner_cleans_up_on_exception() -> None: + """SHOW_CURSOR written even when body raises.""" + stream = io.StringIO() + stream.isatty = lambda: True # type: ignore[method-assign] + + msg = "deliberate" + with ( + pytest.raises(ValueError), + Spinner(message="Test", color_mode=ColorMode.ALWAYS, stream=stream), + ): + raise ValueError(msg) + + assert SHOW_CURSOR in stream.getvalue() + + +def test_spinner_update_message_thread_safe() -> None: + """update_message() can be called from the main thread without error.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner(message="Start", color_mode=ColorMode.NEVER, stream=stream) + spinner.update_message("Updated") + assert spinner.message == "Updated" + + +def test_spinner_add_output_line_accumulates() -> None: + """add_output_line() appends stripped lines to the panel deque on TTY.""" + stream = io.StringIO() + stream.isatty = lambda: True # type: ignore[method-assign] + + spinner = Spinner(message="Test", color_mode=ColorMode.NEVER, stream=stream) + spinner.add_output_line("Session created: test\n") + spinner.add_output_line("Creating window: editor") + spinner.add_output_line("") # blank lines are ignored + + assert list(spinner._output_lines) == [ + "Session created: test", + "Creating window: editor", + ] + + +def test_spinner_panel_respects_maxlen() -> None: + """Panel deque enforces output_lines maxlen, dropping oldest lines.""" + stream = io.StringIO() + stream.isatty = lambda: True # type: ignore[method-assign] + + spinner = Spinner( + message="Test", color_mode=ColorMode.NEVER, stream=stream, output_lines=3 + ) + for i in range(5): + spinner.add_output_line(f"line {i}") + + panel = list(spinner._output_lines) + assert len(panel) == 3 + assert panel == ["line 2", "line 3", "line 4"] + + +def test_spinner_panel_rendered_in_output() -> None: + """Enabled spinner writes panel lines and spinner line to stream.""" + stream = io.StringIO() + stream.isatty = lambda: True # type: ignore[method-assign] + + with Spinner( + message="Building...", color_mode=ColorMode.ALWAYS, stream=stream, interval=0.01 + ) as spinner: + spinner.add_output_line("Session created: my-session") + # Wait long enough for the spinner thread to render at least one frame + # that includes the panel line (interval=0.01s, so 0.05s is sufficient). + time.sleep(0.05) + + output = stream.getvalue() + assert HIDE_CURSOR in output + assert SHOW_CURSOR in output + assert "Session created: my-session" in output + assert "Building..." in output + + +# BuildTree tests + + +def test_build_tree_empty_renders_nothing() -> None: + """BuildTree.render() returns [] before any session_created event.""" + colors = ColorMode.NEVER + tree = BuildTree() + from tmuxp.cli._colors import Colors + + assert tree.render(Colors(colors), 80) == [] + + +def test_build_tree_session_created_shows_header() -> None: + """After session_created, render() returns the 'Session' heading line.""" + from tmuxp.cli._colors import Colors + + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "my-session"}) + lines = tree.render(Colors(ColorMode.NEVER), 80) + assert lines == ["Session"] + + +def test_build_tree_window_started_no_pane_yet() -> None: + """window_started adds a window line with just the name (no pane info).""" + from tmuxp.cli._colors import Colors + + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "my-session"}) + tree.on_event({"event": "window_started", "name": "editor", "pane_total": 2}) + lines = tree.render(Colors(ColorMode.NEVER), 80) + assert len(lines) == 2 + assert lines[1] == "- editor" + + +def test_build_tree_pane_creating_shows_progress() -> None: + """pane_creating updates the last window to show pane N of M.""" + from tmuxp.cli._colors import Colors + + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "my-session"}) + tree.on_event({"event": "window_started", "name": "editor", "pane_total": 3}) + tree.on_event({"event": "pane_creating", "pane_num": 2, "pane_total": 3}) + lines = tree.render(Colors(ColorMode.NEVER), 80) + assert lines[1] == "- editor, pane (2 of 3)" + + +def test_build_tree_window_done_shows_checkmark() -> None: + """window_done marks the window as done; render shows checkmark.""" + from tmuxp.cli._colors import Colors + + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "my-session"}) + tree.on_event({"event": "window_started", "name": "editor", "pane_total": 1}) + tree.on_event({"event": "pane_creating", "pane_num": 1, "pane_total": 1}) + tree.on_event({"event": "window_done"}) + lines = tree.render(Colors(ColorMode.NEVER), 80) + assert lines[1] == "- ✓ editor" + + +def test_build_tree_workspace_built_marks_all_done() -> None: + """workspace_built marks all windows as done.""" + from tmuxp.cli._colors import Colors + + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "my-session"}) + tree.on_event({"event": "window_started", "name": "editor", "pane_total": 1}) + tree.on_event({"event": "window_started", "name": "logs", "pane_total": 1}) + tree.on_event({"event": "workspace_built"}) + lines = tree.render(Colors(ColorMode.NEVER), 80) + assert lines[1] == "- ✓ editor" + assert lines[2] == "- ✓ logs" + + +def test_build_tree_multiple_windows_accumulate() -> None: + """Multiple window_started events accumulate into separate tree lines.""" + from tmuxp.cli._colors import Colors + + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "my-session"}) + tree.on_event({"event": "window_started", "name": "editor", "pane_total": 2}) + tree.on_event({"event": "window_done"}) + tree.on_event({"event": "window_started", "name": "logging", "pane_total": 1}) + tree.on_event({"event": "pane_creating", "pane_num": 1, "pane_total": 1}) + lines = tree.render(Colors(ColorMode.NEVER), 80) + assert lines[1] == "- ✓ editor" + assert lines[2] == "- logging, pane (1 of 1)" + + +def test_spinner_on_build_event_delegates_to_tree() -> None: + """Spinner.on_build_event() updates the internal BuildTree state.""" + import io + + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner(message="Building...", color_mode=ColorMode.NEVER, stream=stream) + spinner.on_build_event({"event": "session_created", "name": "test-session"}) + spinner.on_build_event( + {"event": "window_started", "name": "editor", "pane_total": 1} + ) + + assert spinner._build_tree.session_name == "test-session" + assert len(spinner._build_tree.windows) == 1 + assert spinner._build_tree.windows[0].name == "editor" + + +# BuildTree.format_inline tests + + +def test_build_tree_format_inline_empty() -> None: + """format_inline returns base unchanged when no session has been created.""" + tree = BuildTree() + assert tree.format_inline("Building projects...") == "Building projects..." + + +def test_build_tree_format_inline_session_only() -> None: + """format_inline returns 'base session' after session_created with no windows.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + assert tree.format_inline("Building projects...") == "Building projects... cihai" + + +def test_build_tree_format_inline_with_window_total() -> None: + """format_inline shows window index/total bracket after window_started.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + tree.on_event({"event": "window_started", "name": "gp-libs", "pane_total": 2}) + result = tree.format_inline("Building projects...") + assert result == "Building projects... cihai [1 of 3 windows] gp-libs" + + +def test_build_tree_format_inline_with_panes() -> None: + """format_inline includes pane progress once pane_creating fires.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + tree.on_event({"event": "window_started", "name": "gp-libs", "pane_total": 2}) + tree.on_event({"event": "pane_creating", "pane_num": 1, "pane_total": 2}) + result = tree.format_inline("Building projects...") + assert result == "Building projects... cihai [1 of 3 windows, 1 of 2 panes] gp-libs" + + +def test_build_tree_format_inline_no_window_total() -> None: + """format_inline omits window count bracket when window_total is absent.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai"}) + tree.on_event({"event": "window_started", "name": "main", "pane_total": 1}) + tree.on_event({"event": "pane_creating", "pane_num": 1, "pane_total": 1}) + result = tree.format_inline("Building...") + assert result == "Building... cihai [1 of 1 panes] main" + + +def test_spinner_on_build_event_updates_message() -> None: + """on_build_event updates spinner.message via format_inline after each event.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Building...", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format=None, + ) + assert spinner.message == "Building..." + + spinner.on_build_event( + {"event": "session_created", "name": "cihai", "window_total": 2} + ) + assert spinner.message == "Building... cihai" + + spinner.on_build_event( + {"event": "window_started", "name": "editor", "pane_total": 3} + ) + assert spinner.message == "Building... cihai [1 of 2 windows] editor" + + spinner.on_build_event({"event": "pane_creating", "pane_num": 2, "pane_total": 3}) + assert spinner.message == "Building... cihai [1 of 2 windows, 2 of 3 panes] editor" + + +# resolve_progress_format tests + + +def test_resolve_progress_format_preset_name() -> None: + """A known preset name resolves to its format string.""" + assert resolve_progress_format("default") == PROGRESS_PRESETS["default"] + assert resolve_progress_format("minimal") == PROGRESS_PRESETS["minimal"] + assert resolve_progress_format("verbose") == PROGRESS_PRESETS["verbose"] + + +def test_resolve_progress_format_raw_string() -> None: + """A raw template string is returned unchanged.""" + raw = "{session} w{window_progress}" + assert resolve_progress_format(raw) == raw + + +def test_resolve_progress_format_unknown_name() -> None: + """An unknown name not in presets is returned as-is (raw template pass-through).""" + assert resolve_progress_format("not-a-preset") == "not-a-preset" + + +# BuildTree.format_template tests + + +def test_build_tree_format_template_before_session() -> None: + """format_template returns '' before session_created fires.""" + tree = BuildTree() + assert tree.format_template("{session} [{progress}] {window}") == "" + + +def test_build_tree_format_template_session_only() -> None: + """After session_created alone, progress and window are empty.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + assert tree.format_template("{session} [{progress}] {window}") == "cihai [] " + + +def test_build_tree_format_template_with_window() -> None: + """After window_started, window progress appears but pane progress does not.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + tree.on_event({"event": "window_started", "name": "editor", "pane_total": 4}) + assert ( + tree.format_template("{session} [{progress}] {window}") + == "cihai [1/3 win] editor" + ) + + +def test_build_tree_format_template_with_pane() -> None: + """After pane_creating, both window and pane progress appear.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + tree.on_event({"event": "window_started", "name": "editor", "pane_total": 4}) + tree.on_event({"event": "pane_creating", "pane_num": 2, "pane_total": 4}) + assert ( + tree.format_template("{session} [{progress}] {window}") + == "cihai [1/3 win · 2/4 pane] editor" + ) + + +def test_build_tree_format_template_minimal() -> None: + """The minimal preset-style template shows only window fraction.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + tree.on_event({"event": "window_started", "name": "editor", "pane_total": 4}) + assert tree.format_template("{session} [{window_progress}]") == "cihai [1/3]" + + +def test_build_tree_format_template_verbose() -> None: + """Verbose template shows window/pane indices and totals explicitly.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 12}) + tree.on_event({"event": "window_started", "name": "editor", "pane_total": 4}) + tree.on_event({"event": "pane_creating", "pane_num": 2, "pane_total": 4}) + result = tree.format_template(PROGRESS_PRESETS["verbose"]) + assert result == "Loading workspace: cihai [window 1 of 12 · pane 2 of 4] editor" + + +def test_build_tree_format_template_bad_token() -> None: + """Unknown tokens are left as {name}, known tokens still resolve.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + result = tree.format_template("{session} {unknown}") + # _SafeFormatMap: {session} resolves, {unknown} stays as-is + assert result == "cihai {unknown}" + + +# Spinner.progress_format integration tests + + +def test_spinner_progress_format_updates_message() -> None: + """Spinner with explicit progress_format uses format_template for updates.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + # Use an explicit format string rather than "default" preset to avoid + # coupling this test to the preset definition (which now includes {bar}). + spinner = Spinner( + message="Building...", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format="{session} [{progress}] {window}", + ) + assert spinner.message == "Building..." + + spinner.on_build_event( + {"event": "session_created", "name": "cihai", "window_total": 3} + ) + # No windows yet — falls back to base message to avoid showing empty brackets. + assert spinner.message == "Building..." + + spinner.on_build_event( + {"event": "window_started", "name": "editor", "pane_total": 4} + ) + assert spinner.message == "cihai [1/3 win] editor" + + spinner.on_build_event({"event": "pane_creating", "pane_num": 2, "pane_total": 4}) + assert spinner.message == "cihai [1/3 win · 2/4 pane] editor" + + +def test_spinner_progress_format_none_uses_inline() -> None: + """Spinner with progress_format=None preserves the format_inline path.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Building...", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format=None, + ) + + spinner.on_build_event( + {"event": "session_created", "name": "cihai", "window_total": 2} + ) + assert spinner.message == "Building... cihai" + + spinner.on_build_event( + {"event": "window_started", "name": "editor", "pane_total": 3} + ) + assert spinner.message == "Building... cihai [1 of 2 windows] editor" + + +# render_bar tests + + +def test_render_bar_empty() -> None: + """render_bar with done=0 produces an all-empty bar.""" + assert render_bar(0, 10) == "░░░░░░░░░░" + + +def test_render_bar_half() -> None: + """render_bar with done=5, total=10 fills exactly half.""" + assert render_bar(5, 10) == "█████░░░░░" + + +def test_render_bar_full() -> None: + """render_bar with done=total fills the entire bar.""" + assert render_bar(10, 10) == "██████████" + + +def test_render_bar_zero_total() -> None: + """render_bar with total=0 returns empty string.""" + assert render_bar(0, 0) == "" + + +def test_render_bar_custom_width() -> None: + """render_bar with custom width produces bar of that inner width.""" + assert render_bar(3, 10, width=5) == "█░░░░" + + +def test_render_bar_width_constant() -> None: + """BAR_WIDTH is the default inner width used by render_bar.""" + bar = render_bar(0, 10) + assert len(bar) == BAR_WIDTH + + +# BuildTree new token tests + + +def test_build_tree_context_session_pane_total() -> None: + """session_pane_total token reflects count from session_created event.""" + tree = BuildTree() + tree.on_event( + { + "event": "session_created", + "name": "s", + "window_total": 2, + "session_pane_total": 8, + } + ) + ctx = tree._context() + assert ctx["session_pane_total"] == 8 + assert ctx["session_pane_progress"] == "0/8" + assert ctx["overall_percent"] == 0 + + +def test_build_tree_context_window_progress_rel() -> None: + """window_progress_rel is 0/N from session_created, increments on window_done.""" + tree = BuildTree() + tree.on_event( + { + "event": "session_created", + "name": "s", + "window_total": 3, + "session_pane_total": 6, + } + ) + assert tree._context()["window_progress_rel"] == "0/3" + + tree.on_event({"event": "window_started", "name": "w1", "pane_total": 2}) + assert tree._context()["window_progress_rel"] == "0/3" + + tree.on_event({"event": "window_done"}) + assert tree._context()["window_progress_rel"] == "1/3" + + +def test_build_tree_context_pane_progress_rel() -> None: + """pane_progress_rel shows 0/M after window_started, N/M after pane_creating.""" + tree = BuildTree() + tree.on_event( + { + "event": "session_created", + "name": "s", + "window_total": 1, + "session_pane_total": 4, + } + ) + tree.on_event({"event": "window_started", "name": "w1", "pane_total": 4}) + assert tree._context()["pane_progress_rel"] == "0/4" + + tree.on_event({"event": "pane_creating", "pane_num": 2, "pane_total": 4}) + assert tree._context()["pane_progress_rel"] == "2/4" + assert tree._context()["pane_done"] == 2 + assert tree._context()["pane_remaining"] == 2 + + +def test_build_tree_context_overall_percent() -> None: + """overall_percent is pane-based 0-100; updates on window_done.""" + tree = BuildTree() + tree.on_event( + { + "event": "session_created", + "name": "s", + "window_total": 2, + "session_pane_total": 8, + } + ) + assert tree._context()["overall_percent"] == 0 + + tree.on_event({"event": "window_started", "name": "w1", "pane_total": 4}) + tree.on_event({"event": "window_done"}) + assert tree._context()["session_panes_done"] == 4 + assert tree._context()["overall_percent"] == 50 + + +def test_build_tree_before_script_event_toggle() -> None: + """before_script_started sets the Event; before_script_done clears it.""" + tree = BuildTree() + assert not tree._before_script_event.is_set() + + tree.on_event({"event": "before_script_started"}) + assert tree._before_script_event.is_set() + + tree.on_event({"event": "before_script_done"}) + assert not tree._before_script_event.is_set() + + +def test_build_tree_zero_pane_window() -> None: + """Windows with pane_total=0 do not cause division-by-zero or exceptions.""" + tree = BuildTree() + tree.on_event( + { + "event": "session_created", + "name": "s", + "window_total": 1, + "session_pane_total": 0, + } + ) + tree.on_event({"event": "window_started", "name": "w1", "pane_total": 0}) + tree.on_event({"event": "window_done"}) + + assert tree.session_panes_done == 0 + assert tree.windows_done == 1 + ctx = tree._context() + assert ctx["session_pane_progress"] == "" + assert ctx["overall_percent"] == 0 + + +def test_format_template_extra_backward_compat() -> None: + """format_template(fmt) without extra still works as before.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + result = tree.format_template("{session} [{progress}] {window}") + assert result == "cihai [] " + + +def test_format_template_extra_injected() -> None: + """format_template resolves extra tokens from the extra dict.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + result = tree.format_template("{session} {bar}", extra={"bar": "[TEST_BAR]"}) + assert result == "cihai [TEST_BAR]" + + +def test_format_template_unknown_token_preserved() -> None: + """Unknown tokens in the format string render as {name}, not blank or raw fmt.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + result = tree.format_template("{session} {unknown_token}") + assert result == "cihai {unknown_token}" + + +# Spinner bar token tests + + +def test_spinner_bar_token_no_color() -> None: + """With ColorMode.NEVER, {bar} token in message contains bar characters.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Building...", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format="{session} {bar} {progress} {window}", + ) + spinner.on_build_event( + { + "event": "session_created", + "name": "cihai", + "window_total": 3, + "session_pane_total": 6, + } + ) + spinner.on_build_event( + {"event": "window_started", "name": "editor", "pane_total": 2} + ) + spinner.on_build_event({"event": "pane_creating", "pane_num": 1, "pane_total": 2}) + + assert "░" in spinner.message or "█" in spinner.message + + +def test_spinner_pane_bar_preset() -> None: + """The 'pane' preset wires {pane_bar} and {session_pane_progress}.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Building...", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format="pane", + ) + spinner.on_build_event( + { + "event": "session_created", + "name": "s", + "window_total": 2, + "session_pane_total": 4, + } + ) + spinner.on_build_event({"event": "window_started", "name": "w1", "pane_total": 2}) + spinner.on_build_event({"event": "window_done"}) + + assert "2/4" in spinner.message + assert "░" in spinner.message or "█" in spinner.message + + +def test_spinner_before_script_event_via_events() -> None: + """before_script_started / before_script_done toggle the BuildTree Event flag.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Building...", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format="default", + ) + spinner.on_build_event({"event": "before_script_started"}) + assert spinner._build_tree._before_script_event.is_set() + + spinner.on_build_event({"event": "before_script_done"}) + assert not spinner._build_tree._before_script_event.is_set() + + +def test_progress_presets_have_expected_keys() -> None: + """All expected preset names are present in PROGRESS_PRESETS.""" + for name in ("default", "minimal", "window", "pane", "verbose"): + assert name in PROGRESS_PRESETS, f"Missing preset: {name}" + + +def test_progress_presets_default_includes_bar() -> None: + """The 'default' preset includes the {bar} token.""" + assert "{bar}" in PROGRESS_PRESETS["default"] + + +def test_progress_presets_minimal_format() -> None: + """The 'minimal' preset includes the Loading prefix and window_progress token.""" + expected = "Loading workspace: {session} [{window_progress}]" + assert PROGRESS_PRESETS["minimal"] == expected + + +# BuildTree remaining token tests + + +class RemainingTokenFixture(t.NamedTuple): + """Test fixture for windows_remaining and session_panes_remaining tokens.""" + + test_id: str + events: list[dict[str, t.Any]] + token: str + expected: int + + +REMAINING_TOKEN_FIXTURES: list[RemainingTokenFixture] = [ + RemainingTokenFixture( + "windows_remaining_initial", + [ + { + "event": "session_created", + "name": "s", + "window_total": 3, + "session_pane_total": 6, + }, + ], + "windows_remaining", + 3, + ), + RemainingTokenFixture( + "windows_remaining_after_done", + [ + { + "event": "session_created", + "name": "s", + "window_total": 3, + "session_pane_total": 6, + }, + {"event": "window_started", "name": "w1", "pane_total": 2}, + {"event": "window_done"}, + ], + "windows_remaining", + 2, + ), + RemainingTokenFixture( + "session_panes_remaining_initial", + [ + { + "event": "session_created", + "name": "s", + "window_total": 2, + "session_pane_total": 5, + }, + ], + "session_panes_remaining", + 5, + ), + RemainingTokenFixture( + "session_panes_remaining_after_window", + [ + { + "event": "session_created", + "name": "s", + "window_total": 2, + "session_pane_total": 5, + }, + {"event": "window_started", "name": "w1", "pane_total": 3}, + {"event": "window_done"}, + ], + "session_panes_remaining", + 2, + ), +] + + +@pytest.mark.parametrize( + list(RemainingTokenFixture._fields), + REMAINING_TOKEN_FIXTURES, + ids=[f.test_id for f in REMAINING_TOKEN_FIXTURES], +) +def test_build_tree_remaining_tokens( + test_id: str, + events: list[dict[str, t.Any]], + token: str, + expected: int, +) -> None: + """Remaining tokens decrement correctly as windows/panes complete.""" + tree = BuildTree() + for ev in events: + tree.on_event(ev) + assert tree._context()[token] == expected + + +# _visible_len tests + + +class VisibleLenFixture(t.NamedTuple): + """Test fixture for _visible_len ANSI-aware length calculation.""" + + test_id: str + text: str + expected_len: int + + +VISIBLE_LEN_FIXTURES: list[VisibleLenFixture] = [ + VisibleLenFixture("plain_text", "hello", 5), + VisibleLenFixture("ansi_green", "\033[32mgreen\033[0m", 5), + VisibleLenFixture("empty_string", "", 0), + VisibleLenFixture("nested_ansi", "\033[1m\033[31mbold red\033[0m", 8), + VisibleLenFixture("ansi_only", "\033[0m", 0), +] + + +@pytest.mark.parametrize( + list(VisibleLenFixture._fields), + VISIBLE_LEN_FIXTURES, + ids=[f.test_id for f in VISIBLE_LEN_FIXTURES], +) +def test_visible_len( + test_id: str, + text: str, + expected_len: int, +) -> None: + """_visible_len returns the visible character count, ignoring ANSI escapes.""" + assert _visible_len(text) == expected_len + + +# Spinner.add_output_line non-TTY write-through tests + + +class OutputLineFixture(t.NamedTuple): + """Test fixture for add_output_line TTY vs non-TTY behavior.""" + + test_id: str + isatty: bool + lines: list[str] + expected_deque: list[str] + expected_stream_contains: str + + +OUTPUT_LINE_FIXTURES: list[OutputLineFixture] = [ + OutputLineFixture( + "tty_accumulates_in_deque", + isatty=True, + lines=["line1\n", "line2\n"], + expected_deque=["line1", "line2"], + expected_stream_contains="", + ), + OutputLineFixture( + "non_tty_writes_to_stream", + isatty=False, + lines=["hello\n", "world\n"], + expected_deque=[], + expected_stream_contains="hello\nworld\n", + ), + OutputLineFixture( + "blank_lines_ignored", + isatty=True, + lines=["", "\n"], + expected_deque=[], + expected_stream_contains="", + ), +] + + +@pytest.mark.parametrize( + list(OutputLineFixture._fields), + OUTPUT_LINE_FIXTURES, + ids=[f.test_id for f in OUTPUT_LINE_FIXTURES], +) +def test_spinner_output_line_behavior( + test_id: str, + isatty: bool, + lines: list[str], + expected_deque: list[str], + expected_stream_contains: str, +) -> None: + """add_output_line accumulates in deque (TTY) or writes to stream (non-TTY).""" + stream = io.StringIO() + stream.isatty = lambda: isatty # type: ignore[method-assign] + + spinner = Spinner(message="Test", color_mode=ColorMode.NEVER, stream=stream) + for line in lines: + spinner.add_output_line(line) + + assert list(spinner._output_lines) == expected_deque + assert expected_stream_contains in stream.getvalue() + + +# Spinner.success tests + + +# Panel lines special values tests + + +class PanelLinesFixture(t.NamedTuple): + """Test fixture for Spinner panel_lines special values.""" + + test_id: str + output_lines: int + expected_maxlen: int | None # None = unbounded + expected_hidden: bool + add_count: int + expected_retained: int + + +PANEL_LINES_FIXTURES: list[PanelLinesFixture] = [ + PanelLinesFixture("zero_hides_panel", 0, 1, True, 10, 1), + PanelLinesFixture("negative_unlimited", -1, None, False, 100, 100), + PanelLinesFixture("positive_normal", 5, 5, False, 10, 5), + PanelLinesFixture("default_three", 3, 3, False, 5, 3), +] + + +@pytest.mark.parametrize( + list(PanelLinesFixture._fields), + PANEL_LINES_FIXTURES, + ids=[f.test_id for f in PANEL_LINES_FIXTURES], +) +def test_spinner_panel_lines_special_values( + test_id: str, + output_lines: int, + expected_maxlen: int | None, + expected_hidden: bool, + add_count: int, + expected_retained: int, +) -> None: + """Spinner panel_lines=0 hides, -1 is unlimited, positive caps normally.""" + stream = io.StringIO() + stream.isatty = lambda: True # type: ignore[method-assign] + + spinner = Spinner( + message="Test", + color_mode=ColorMode.NEVER, + stream=stream, + output_lines=output_lines, + ) + for i in range(add_count): + spinner.add_output_line(f"line {i}") + + assert len(spinner._output_lines) == expected_retained + assert spinner._output_lines.maxlen == expected_maxlen + assert spinner._panel_hidden is expected_hidden + + +def test_spinner_unlimited_caps_rendered_panel( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Unlimited panel (-1) caps rendered lines to terminal_height - 2.""" + import os as _os + import shutil + + monkeypatch.setattr( + shutil, + "get_terminal_size", + lambda fallback=(80, 24): _os.terminal_size((80, 10)), + ) + + stream = io.StringIO() + stream.isatty = lambda: True # type: ignore[method-assign] + + spinner = Spinner( + message="Test", + color_mode=ColorMode.NEVER, + stream=stream, + output_lines=-1, + interval=0.01, + ) + for i in range(50): + spinner.add_output_line(f"line {i}") + + # All 50 lines should be retained in the unbounded deque + assert len(spinner._output_lines) == 50 + + # Start spinner briefly to render at least one frame + spinner.start() + time.sleep(0.05) + spinner.stop() + + output = stream.getvalue() + # Verify that not all 50 lines appear in any single frame + # The cap should limit to terminal_height - 2 = 8 lines + # Only the last 8 lines should appear in output + assert "line 49" in output + assert "line 0" not in output + + +class SuccessFixture(t.NamedTuple): + """Test fixture for Spinner.success() output behavior.""" + + test_id: str + isatty: bool + color_mode: ColorMode + expected_contains: str + + +SUCCESS_FIXTURES: list[SuccessFixture] = [ + SuccessFixture("tty_with_color", True, ColorMode.ALWAYS, "done"), + SuccessFixture("tty_no_color", True, ColorMode.NEVER, "✓ done"), + SuccessFixture("non_tty", False, ColorMode.NEVER, "✓ done"), +] + + +@pytest.mark.parametrize( + list(SuccessFixture._fields), + SUCCESS_FIXTURES, + ids=[f.test_id for f in SUCCESS_FIXTURES], +) +def test_spinner_success_behavior( + test_id: str, + isatty: bool, + color_mode: ColorMode, + expected_contains: str, +) -> None: + """success() always emits the checkmark message regardless of TTY/color mode.""" + stream = io.StringIO() + stream.isatty = lambda: isatty # type: ignore[method-assign] + + spinner = Spinner(message="Test", color_mode=color_mode, stream=stream) + spinner.success("done") + + output = stream.getvalue() + assert "✓" in output + assert expected_contains in output + + +# _truncate_visible tests + + +def test_truncate_visible_plain_text() -> None: + """Plain text is truncated to max_visible chars with default suffix.""" + assert _truncate_visible("hello world", 5) == "hello\x1b[0m..." + + +def test_truncate_visible_ansi_preserved() -> None: + """ANSI sequences are preserved whole; only visible chars count.""" + result = _truncate_visible("\033[32mgreen\033[0m", 3) + assert result == "\x1b[32mgre\x1b[0m..." + + +def test_truncate_visible_no_truncation() -> None: + """String shorter than max_visible is returned unchanged.""" + assert _truncate_visible("short", 10) == "short" + + +def test_truncate_visible_empty() -> None: + """Empty string returns empty string.""" + assert _truncate_visible("", 5) == "" + + +def test_truncate_visible_custom_suffix() -> None: + """Custom suffix is appended after truncation.""" + assert _truncate_visible("hello world", 5, suffix="~") == "hello\x1b[0m~" + + +def test_truncate_visible_no_suffix() -> None: + """Empty suffix produces only the reset sequence.""" + assert _truncate_visible("hello world", 5, suffix="") == "hello\x1b[0m" + + +# workspace_path token tests + + +def test_build_tree_workspace_path_in_context() -> None: + """workspace_path is available in _context() when set on construction.""" + tree = BuildTree(workspace_path="~/.tmuxp/foo.yaml") + tree.on_event({"event": "session_created", "name": "foo", "window_total": 1}) + ctx = tree._context() + assert ctx["workspace_path"] == "~/.tmuxp/foo.yaml" + + +def test_build_tree_workspace_path_empty_default() -> None: + """workspace_path defaults to empty string in _context().""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "s", "window_total": 1}) + assert tree._context()["workspace_path"] == "" + + +def test_spinner_workspace_path_passed_to_tree() -> None: + """Spinner passes workspace_path through to its BuildTree.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Loading...", + color_mode=ColorMode.NEVER, + stream=stream, + workspace_path="~/.tmuxp/proj.yaml", + ) + assert spinner._build_tree.workspace_path == "~/.tmuxp/proj.yaml" + + +def test_build_tree_workspace_path_in_template() -> None: + """workspace_path token resolves in format_template.""" + tree = BuildTree(workspace_path="~/.tmuxp/bar.yaml") + tree.on_event({"event": "session_created", "name": "bar", "window_total": 1}) + result = tree.format_template("{session} ({workspace_path})") + assert result == "bar (~/.tmuxp/bar.yaml)" + + +# {summary} token tests + + +def test_build_tree_summary_empty_state() -> None: + """Summary token is empty string before any windows complete.""" + tree = BuildTree() + tree.on_event( + { + "event": "session_created", + "name": "s", + "window_total": 3, + "session_pane_total": 6, + } + ) + assert tree._context()["summary"] == "" + + +def test_build_tree_summary_after_windows_done() -> None: + """Summary token shows bracketed win/pane counts after windows complete.""" + tree = BuildTree() + tree.on_event( + { + "event": "session_created", + "name": "s", + "window_total": 3, + "session_pane_total": 8, + } + ) + tree.on_event({"event": "window_started", "name": "w1", "pane_total": 3}) + tree.on_event({"event": "window_done"}) + tree.on_event({"event": "window_started", "name": "w2", "pane_total": 2}) + tree.on_event({"event": "window_done"}) + tree.on_event({"event": "window_started", "name": "w3", "pane_total": 3}) + tree.on_event({"event": "window_done"}) + assert tree._context()["summary"] == "[3 win, 8 panes]" + + +def test_build_tree_summary_windows_only_no_panes() -> None: + """Summary token shows only win count when pane_total is 0.""" + tree = BuildTree() + tree.on_event( + { + "event": "session_created", + "name": "s", + "window_total": 2, + "session_pane_total": 0, + } + ) + tree.on_event({"event": "window_started", "name": "w1", "pane_total": 0}) + tree.on_event({"event": "window_done"}) + tree.on_event({"event": "window_started", "name": "w2", "pane_total": 0}) + tree.on_event({"event": "window_done"}) + assert tree._context()["summary"] == "[2 win]" + + +def test_build_tree_summary_panes_only() -> None: + """Summary token shows only pane count when windows_done is 0 (edge case).""" + tree = BuildTree() + tree.on_event( + { + "event": "session_created", + "name": "s", + "window_total": 1, + "session_pane_total": 6, + } + ) + # Manually set session_panes_done without window_done to test edge case + tree.session_panes_done = 6 + assert tree._context()["summary"] == "[6 panes]" + + +# format_success() tests + + +def test_spinner_format_success_full_build() -> None: + """format_success renders SUCCESS_TEMPLATE with session, path, and summary.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Loading...", + color_mode=ColorMode.NEVER, + stream=stream, + workspace_path="~/.tmuxp/myapp.yaml", + ) + spinner._build_tree.on_event( + { + "event": "session_created", + "name": "myapp", + "window_total": 3, + "session_pane_total": 8, + } + ) + spinner._build_tree.on_event( + {"event": "window_started", "name": "w1", "pane_total": 3} + ) + spinner._build_tree.on_event({"event": "window_done"}) + spinner._build_tree.on_event( + {"event": "window_started", "name": "w2", "pane_total": 2} + ) + spinner._build_tree.on_event({"event": "window_done"}) + spinner._build_tree.on_event( + {"event": "window_started", "name": "w3", "pane_total": 3} + ) + spinner._build_tree.on_event({"event": "window_done"}) + + result = spinner.format_success() + assert "Loaded workspace:" in result + assert "myapp" in result + assert "~/.tmuxp/myapp.yaml" in result + assert "[3 win, 8 panes]" in result + + +def test_spinner_format_success_no_windows() -> None: + """format_success with no windows/panes done omits brackets.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Loading...", + color_mode=ColorMode.NEVER, + stream=stream, + workspace_path="~/.tmuxp/empty.yaml", + ) + spinner._build_tree.on_event( + { + "event": "session_created", + "name": "empty", + "window_total": 0, + "session_pane_total": 0, + } + ) + + result = spinner.format_success() + assert "Loaded workspace:" in result + assert "empty" in result + assert "~/.tmuxp/empty.yaml" in result + assert "[" not in result + + +# Spinner.success() with no args tests + + +def test_spinner_success_no_args_template_mode() -> None: + """success() with no args uses format_success when progress_format is set.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Loading...", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format="default", + workspace_path="~/.tmuxp/proj.yaml", + ) + spinner._build_tree.on_event( + { + "event": "session_created", + "name": "proj", + "window_total": 1, + "session_pane_total": 2, + } + ) + spinner._build_tree.on_event( + {"event": "window_started", "name": "main", "pane_total": 2} + ) + spinner._build_tree.on_event({"event": "window_done"}) + + spinner.success() + + output = stream.getvalue() + assert "✓" in output + assert "Loaded workspace:" in output + assert "proj" in output + assert "~/.tmuxp/proj.yaml" in output + assert "[1 win, 2 panes]" in output + + +def test_spinner_success_no_args_no_template() -> None: + """success() with no args and no progress_format falls back to _base_message.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Loading workspace: myapp", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format=None, + ) + spinner.success() + + output = stream.getvalue() + assert "✓ Loading workspace: myapp" in output + + +def test_spinner_success_explicit_text_backward_compat() -> None: + """success('custom text') still works as before (backward compat).""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Loading...", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format="default", + ) + spinner.success("custom done message") + + output = stream.getvalue() + assert "✓ custom done message" in output + + +# SUCCESS_TEMPLATE constant tests + + +def test_success_template_value() -> None: + """SUCCESS_TEMPLATE contains expected tokens.""" + assert "{session}" in SUCCESS_TEMPLATE + assert "{workspace_path}" in SUCCESS_TEMPLATE + assert "{summary}" in SUCCESS_TEMPLATE + assert "Loaded workspace:" in SUCCESS_TEMPLATE + + +def test_no_success_message_on_build_error( + server: libtmux.Server, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capfd: pytest.CaptureFixture[str], +) -> None: + """Success message is not emitted when _dispatch_build returns None.""" + import yaml + + from tmuxp.cli._colors import Colors + from tmuxp.cli.load import load_workspace + + monkeypatch.delenv("TMUX", raising=False) + + config = {"session_name": "test-fail", "windows": [{"window_name": "main"}]} + config_file = tmp_path / "fail.yaml" + config_file.write_text(yaml.dump(config)) + + monkeypatch.setattr( + "tmuxp.cli.load._dispatch_build", + lambda *args, **kwargs: None, + ) + + result = load_workspace( + str(config_file), + socket_name=server.socket_name, + cli_colors=Colors(ColorMode.NEVER), + ) + + assert result is None + captured = capfd.readouterr() + assert "\u2713" not in captured.err + assert "Loaded workspace:" not in captured.err diff --git a/tests/workspace/test_progress.py b/tests/workspace/test_progress.py new file mode 100644 index 0000000000..336fa9dafd --- /dev/null +++ b/tests/workspace/test_progress.py @@ -0,0 +1,258 @@ +"""Tests for tmuxp workspace builder progress callback.""" + +from __future__ import annotations + +import typing as t + +import pytest +from libtmux.server import Server + +from tmuxp import exc +from tmuxp.workspace.builder import WorkspaceBuilder + + +def test_builder_on_progress_callback( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """WorkspaceBuilder calls on_progress at each build milestone.""" + monkeypatch.delenv("TMUX", raising=False) + + session_config = { + "session_name": "progress-test", + "windows": [{"window_name": "editor", "panes": [{"shell_command": []}]}], + } + + calls: list[str] = [] + builder = WorkspaceBuilder( + session_config=session_config, + server=server, + on_progress=calls.append, + ) + builder.build() + + assert any("Session created:" in c for c in calls) + assert any("Creating window:" in c for c in calls) + assert any("Creating pane:" in c for c in calls) + assert "Workspace built" in calls + + +def test_builder_on_before_script_not_called_without_script( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """on_before_script callback is not invoked when config has no before_script key.""" + monkeypatch.delenv("TMUX", raising=False) + + session_config = { + "session_name": "no-script-callback-test", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + called: list[bool] = [] + builder = WorkspaceBuilder( + session_config=session_config, + server=server, + on_before_script=lambda: called.append(True), + ) + builder.build() + assert called == [] + + +def test_builder_on_script_output_not_called_without_script( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """on_script_output callback is not invoked when config has no before_script key.""" + monkeypatch.delenv("TMUX", raising=False) + + session_config = { + "session_name": "no-script-output-test", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + lines: list[str] = [] + builder = WorkspaceBuilder( + session_config=session_config, + server=server, + on_script_output=lines.append, + ) + builder.build() + assert lines == [] + + +def test_builder_on_build_event_sequence( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """on_build_event fires the full event sequence during build().""" + monkeypatch.delenv("TMUX", raising=False) + + session_config = { + "session_name": "build-event-test", + "windows": [ + { + "window_name": "editor", + "panes": [{"shell_command": []}, {"shell_command": []}], + }, + {"window_name": "logs", "panes": [{"shell_command": []}]}, + ], + } + events: list[dict[str, t.Any]] = [] + builder = WorkspaceBuilder( + session_config=session_config, + server=server, + on_build_event=events.append, + ) + builder.build() + + event_types = [e["event"] for e in events] + assert event_types[0] == "session_created" + assert event_types[-1] == "workspace_built" + assert event_types.count("window_started") == 2 + assert event_types.count("window_done") == 2 + assert event_types.count("pane_creating") == 3 # 2 panes + 1 pane + + created = next(e for e in events if e["event"] == "session_created") + assert created["window_total"] == 2 + assert created["session_pane_total"] == 3 # 2 panes + 1 pane + + +def test_builder_on_build_event_session_name( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """session_created event carries correct session name.""" + monkeypatch.delenv("TMUX", raising=False) + + session_config = { + "session_name": "name-check", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + events: list[dict[str, t.Any]] = [] + builder = WorkspaceBuilder( + session_config=session_config, + server=server, + on_build_event=events.append, + ) + builder.build() + + created = next(e for e in events if e["event"] == "session_created") + assert created["name"] == "name-check" + assert created["window_total"] == 1 + + +def test_builder_on_build_event_session_pane_total( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """session_created event includes session_pane_total summing all windows' panes.""" + monkeypatch.delenv("TMUX", raising=False) + + pane: dict[str, list[object]] = {"shell_command": []} + session_config = { + "session_name": "pane-total-test", + "windows": [ + {"window_name": "w1", "panes": [pane, pane]}, + {"window_name": "w2", "panes": [pane]}, + {"window_name": "w3", "panes": [pane, pane, pane]}, + ], + } + events: list[dict[str, t.Any]] = [] + builder = WorkspaceBuilder( + session_config=session_config, + server=server, + on_build_event=events.append, + ) + builder.build() + + created = next(e for e in events if e["event"] == "session_created") + assert created["session_pane_total"] == 6 # 2 + 1 + 3 + + +def test_builder_before_script_events( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """before_script_started fires before run; before_script_done fires in finally.""" + monkeypatch.delenv("TMUX", raising=False) + + session_config = { + "session_name": "before-script-events-test", + "before_script": "echo hello", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + events: list[dict[str, t.Any]] = [] + builder = WorkspaceBuilder( + session_config=session_config, + server=server, + on_build_event=events.append, + ) + builder.build() + + event_types = [e["event"] for e in events] + assert "before_script_started" in event_types + assert "before_script_done" in event_types + + bs_start_idx = event_types.index("before_script_started") + bs_done_idx = event_types.index("before_script_done") + win_idx = event_types.index("window_started") + assert bs_start_idx < bs_done_idx < win_idx + + +def test_builder_before_script_done_fires_on_failure( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """before_script_done fires in finally even when the script fails.""" + monkeypatch.delenv("TMUX", raising=False) + + session_config = { + "session_name": "before-script-fail-test", + "before_script": "/bin/false", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + events: list[dict[str, t.Any]] = [] + builder = WorkspaceBuilder( + session_config=session_config, + server=server, + on_build_event=events.append, + ) + with pytest.raises(exc.BeforeLoadScriptError): + builder.build() + + event_types = [e["event"] for e in events] + assert "before_script_started" in event_types + assert "before_script_done" in event_types + + +def test_builder_on_build_event_pane_numbers( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """pane_creating events carry 1-based pane_num and correct pane_total.""" + monkeypatch.delenv("TMUX", raising=False) + + session_config = { + "session_name": "pane-num-test", + "windows": [ + { + "window_name": "main", + "panes": [ + {"shell_command": []}, + {"shell_command": []}, + {"shell_command": []}, + ], + }, + ], + } + events: list[dict[str, t.Any]] = [] + builder = WorkspaceBuilder( + session_config=session_config, + server=server, + on_build_event=events.append, + ) + builder.build() + + pane_events = [e for e in events if e["event"] == "pane_creating"] + assert len(pane_events) == 3 + assert [e["pane_num"] for e in pane_events] == [1, 2, 3] + assert all(e["pane_total"] == 3 for e in pane_events) From 2e9f8cd4d0c8cd4a453bbdc4d298bdbc428d05ff Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 15:56:24 -0500 Subject: [PATCH 24/50] docs(progress): document progress display, env vars, and API reference why: Users need documentation for the progress spinner feature. what: - Add "Progress display" section to docs/cli/load.md with presets, tokens, panel lines, disabling, and before-script behavior - Add TMUXP_PROGRESS, TMUXP_PROGRESS_FORMAT, TMUXP_PROGRESS_LINES to environmental-variables.md - Add docs/api/cli/progress.md API reference page --- docs/api/cli/index.md | 1 + docs/api/cli/progress.md | 8 ++ docs/cli/load.md | 101 ++++++++++++++++++ docs/configuration/environmental-variables.md | 51 +++++++++ 4 files changed, 161 insertions(+) create mode 100644 docs/api/cli/progress.md diff --git a/docs/api/cli/index.md b/docs/api/cli/index.md index 9289503905..1381fbc90f 100644 --- a/docs/api/cli/index.md +++ b/docs/api/cli/index.md @@ -16,6 +16,7 @@ freeze import_config load ls +progress search shell utils diff --git a/docs/api/cli/progress.md b/docs/api/cli/progress.md new file mode 100644 index 0000000000..3b092349cf --- /dev/null +++ b/docs/api/cli/progress.md @@ -0,0 +1,8 @@ +# tmuxp progress - `tmuxp.cli._progress` + +```{eval-rst} +.. automodule:: tmuxp.cli._progress + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/cli/load.md b/docs/cli/load.md index a362888e15..8be9178f29 100644 --- a/docs/cli/load.md +++ b/docs/cli/load.md @@ -152,3 +152,104 @@ $ tmuxp load [filename] --log-file [log_filename] ```console $ tmuxp --log-level [LEVEL] load [filename] --log-file [log_filename] ``` + +## Progress display + +When loading a workspace, tmuxp shows an animated spinner with build progress. The spinner updates as windows and panes are created, giving real-time feedback during session builds. + +### Presets + +Five built-in presets control the spinner format: + +| Preset | Format | +|--------|--------| +| `default` | `Loading workspace: {session} {bar} {progress} {window}` | +| `minimal` | `Loading workspace: {session} [{window_progress}]` | +| `window` | `Loading workspace: {session} {window_bar} {window_progress_rel}` | +| `pane` | `Loading workspace: {session} {pane_bar} {session_pane_progress}` | +| `verbose` | `Loading workspace: {session} [window {window_index} of {window_total} · pane {pane_index} of {pane_total}] {window}` | + +Select a preset with `--progress-format`: + +```console +$ tmuxp load --progress-format minimal myproject +``` + +Or via environment variable: + +```console +$ TMUXP_PROGRESS_FORMAT=verbose tmuxp load myproject +``` + +### Custom format tokens + +Use a custom format string with any of the available tokens: + +| Token | Description | +|-------|-------------| +| `{session}` | Session name | +| `{window}` | Current window name | +| `{window_index}` | Current window number (1-based) | +| `{window_total}` | Total number of windows | +| `{window_progress}` | Window fraction (e.g. `1/3`) | +| `{window_progress_rel}` | Completed windows fraction (e.g. `1/3`) | +| `{windows_done}` | Number of completed windows | +| `{windows_remaining}` | Number of remaining windows | +| `{pane_index}` | Current pane number in the window | +| `{pane_total}` | Total panes in the current window | +| `{pane_progress}` | Pane fraction (e.g. `2/4`) | +| `{progress}` | Combined progress (e.g. `1/3 win · 2/4 pane`) | +| `{session_pane_progress}` | Panes completed across the session (e.g. `5/10`) | +| `{overall_percent}` | Pane-based completion percentage (0–100) | +| `{bar}` | Composite progress bar | +| `{pane_bar}` | Pane-based progress bar | +| `{window_bar}` | Window-based progress bar | +| `{status_icon}` | Status icon (⏸ during before_script) | + +Example: + +```console +$ tmuxp load --progress-format "{session} {bar} {overall_percent}%" myproject +``` + +### Panel lines + +The spinner shows script output in a panel below the spinner line. Control the panel height with `--progress-lines`: + +Hide the panel entirely (script output goes to stdout): + +```console +$ tmuxp load --progress-lines 0 myproject +``` + +Show unlimited lines (capped to terminal height): + +```console +$ tmuxp load --progress-lines -1 myproject +``` + +Set a custom height (default is 3): + +```console +$ tmuxp load --progress-lines 5 myproject +``` + +### Disabling progress + +Disable the animated spinner entirely: + +```console +$ tmuxp load --no-progress myproject +``` + +Or via environment variable: + +```console +$ TMUXP_PROGRESS=0 tmuxp load myproject +``` + +When progress is disabled, logging flows normally to the terminal and no spinner is rendered. + +### Before-script behavior + +During `before_script` execution, the progress bar shows a marching animation and a ⏸ status icon, indicating that tmuxp is waiting for the script to finish before continuing with pane creation. diff --git a/docs/configuration/environmental-variables.md b/docs/configuration/environmental-variables.md index d485da4574..73c0d1e373 100644 --- a/docs/configuration/environmental-variables.md +++ b/docs/configuration/environmental-variables.md @@ -24,3 +24,54 @@ building sessions. For this case you can override it here. ```console $ env LIBTMUX_TMUX_FORMAT_SEPARATOR='__SEP__' tmuxp load [session] ``` + +(TMUXP_PROGRESS)= + +## `TMUXP_PROGRESS` + +Master on/off switch for the animated progress spinner during `tmuxp load`. +Defaults to `1` (enabled). Set to `0` to disable: + +```console +$ TMUXP_PROGRESS=0 tmuxp load myproject +``` + +Equivalent to the `--no-progress` CLI flag. + +(TMUXP_PROGRESS_FORMAT)= + +## `TMUXP_PROGRESS_FORMAT` + +Set the spinner line format. Accepts a preset name (`default`, `minimal`, `window`, `pane`, `verbose`) or a custom format string with tokens like `{session}`, `{bar}`, `{progress}`: + +```console +$ TMUXP_PROGRESS_FORMAT=minimal tmuxp load myproject +``` + +Custom format example: + +```console +$ TMUXP_PROGRESS_FORMAT="{session} {bar} {overall_percent}%" tmuxp load myproject +``` + +Equivalent to the `--progress-format` CLI flag. + +(TMUXP_PROGRESS_LINES)= + +## `TMUXP_PROGRESS_LINES` + +Number of script-output lines shown in the spinner panel. Defaults to `3`. + +Set to `0` to hide the panel entirely (script output goes to stdout): + +```console +$ TMUXP_PROGRESS_LINES=0 tmuxp load myproject +``` + +Set to `-1` for unlimited lines (capped to terminal height): + +```console +$ TMUXP_PROGRESS_LINES=-1 tmuxp load myproject +``` + +Equivalent to the `--progress-lines` CLI flag. From 96ede9beeda820d297dbcc1ef83347f742301c2d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 15:56:53 -0500 Subject: [PATCH 25/50] docs(CHANGES): add animated progress spinner entry why: PR 2 needs changelog entry for the progress spinner feature. what: - Add "Animated progress spinner" section under What's new --- CHANGES | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CHANGES b/CHANGES index 5e8bf5cdcd..2c3018924e 100644 --- a/CHANGES +++ b/CHANGES @@ -35,6 +35,22 @@ $ pipx install --suffix=@next 'tmuxp' --pip-args '\--pre' --force _Notes on the upcoming release will go here._ +### What's new + +#### Animated progress spinner for `tmuxp load` (#1020) + +The `load` command now shows an animated spinner with real-time build progress +as windows and panes are created. Five built-in presets control the display +format (`default`, `minimal`, `window`, `pane`, `verbose`), and custom format +strings are supported via `--progress-format` or `TMUXP_PROGRESS_FORMAT`. + +- `--progress-lines N` / `TMUXP_PROGRESS_LINES`: Control how many lines of + `before_script` output appear in the spinner panel (default: 3). Use `0` to + hide the panel, `-1` for unlimited (capped to terminal height). +- `--no-progress` / `TMUXP_PROGRESS=0`: Disable the spinner entirely. +- During `before_script` execution, the progress bar shows a marching animation + and ⏸ icon. + ## tmuxp 1.66.0 (2026-03-08) ### Bug fixes From d08589aefbbef115dc456097c0d7001010292412 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 20:24:33 -0500 Subject: [PATCH 26/50] Tag v1.67.0 (load spinner via #1020) --- CHANGES | 4 +++- pyproject.toml | 2 +- src/tmuxp/__about__.py | 2 +- uv.lock | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGES b/CHANGES index 2c3018924e..707359f3ac 100644 --- a/CHANGES +++ b/CHANGES @@ -27,7 +27,7 @@ $ pipx install --suffix=@next 'tmuxp' --pip-args '\--pre' --force // Usage: tmuxp@next load yoursession ``` -## tmuxp 1.67.0 (Yet to be released) +## tmuxp 1.68.0 (Yet to be released) @@ -35,6 +35,8 @@ $ pipx install --suffix=@next 'tmuxp' --pip-args '\--pre' --force _Notes on the upcoming release will go here._ +## tmuxp 1.67.0 (2026-03-08) + ### What's new #### Animated progress spinner for `tmuxp load` (#1020) diff --git a/pyproject.toml b/pyproject.toml index ceb1aae0c9..e394750acc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tmuxp" -version = "1.66.0" +version = "1.67.0" description = "Session manager for tmux, which allows users to save and load tmux sessions through simple configuration files." requires-python = ">=3.10,<4.0" authors = [ diff --git a/src/tmuxp/__about__.py b/src/tmuxp/__about__.py index f1cfd86085..48ad3629a1 100644 --- a/src/tmuxp/__about__.py +++ b/src/tmuxp/__about__.py @@ -8,7 +8,7 @@ __title__ = "tmuxp" __package_name__ = "tmuxp" -__version__ = "1.66.0" +__version__ = "1.67.0" __description__ = "tmux session manager" __email__ = "tony@git-pull.com" __author__ = "Tony Narlock" diff --git a/uv.lock b/uv.lock index 03668ca9d4..5606904017 100644 --- a/uv.lock +++ b/uv.lock @@ -1379,7 +1379,7 @@ wheels = [ [[package]] name = "tmuxp" -version = "1.66.0" +version = "1.67.0" source = { editable = "." } dependencies = [ { name = "libtmux" }, From 6a62a0894e4f0530a064cdac66c71b2bb5b153e7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 12 Mar 2026 17:09:50 -0600 Subject: [PATCH 27/50] docs(style[headings]): compact heading sizes and changelog spacing why: Furo's default heading sizes (h1=2.5em, h2=2em) are disproportionately large for content-dense pages like the changelog. Inspired by biomejs.dev's compact proportions. what: - Reduce global heading sizes ~30% (h1=1.8em, h2=1.4em, h3=1.15em) - Add changelog-specific spacing between consecutive version entries - Mute category headings (h3) with secondary foreground color - Subtle item headings (h4) at body text size --- docs/_static/css/custom.css | 64 +++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index 00a15fdc22..90e02262de 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -19,3 +19,67 @@ .sidebar-tree .active { font-weight: bold; } + +/* ── Global heading refinements ───────────────────────────── + * Reduce Furo's default heading sizes (~30%) for better + * proportions. Furo uses bare h1-h6 selectors, so `article` + * prefix provides sufficient specificity to override. + * ────────────────────────────────────────────────────────── */ +article h1 { + font-size: 1.8em; + margin-top: 1.5rem; + margin-bottom: 0.75rem; +} + +article h2 { + font-size: 1.4em; + margin-top: 1.5rem; + margin-bottom: 0.5rem; +} + +article h3 { + font-size: 1.15em; + font-weight: 600; + margin-bottom: 0.375rem; +} + +article h4 { + font-size: 1.05em; + font-weight: 600; + margin-bottom: 0.25rem; +} + +article h5 { + font-size: 1em; + font-weight: 600; +} + +article h6 { + font-size: 0.875em; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* ── Changelog heading extras ─────────────────────────────── + * Vertical spacing separates consecutive version entries. + * Category headings (h3) are muted. Item headings (h4) are + * subtle. Targets #history section from CHANGES markdown. + * ────────────────────────────────────────────────────────── */ + +/* Spacing between consecutive version entries */ +#history > section + section { + margin-top: 2.5rem; +} + +/* Category headings — muted secondary color */ +#history h3 { + color: var(--color-foreground-secondary); + margin-top: 1.25rem; +} + +/* Item headings — subtle, same size as body */ +#history h4 { + font-size: 1em; + margin-top: 1rem; +} From 2b483f3708e6dfda41b13a2415f71ae1ea41a2de Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 12 Mar 2026 17:15:22 -0600 Subject: [PATCH 28/50] docs(style[toc,body]): refine right-panel TOC and body typography MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Furo's default TOC title (10px) is nearly invisible and smaller than its own items (12px), inverting typographic hierarchy. Body line-height (1.5) is tighter than WCAG-recommended range. what: - Bump TOC item size 75% → 81.25% (12→13px) via --toc-font-size - Bump TOC title size 62.5% → 87.5% (10→14px) via --toc-title-font-size - Increase .toc-tree line-height 1.3 → 1.4 for wrapped entries - Increase article line-height 1.5 → 1.6 for paragraph readability - Enable text-rendering: optimizeLegibility on body --- docs/_static/css/custom.css | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index 90e02262de..079b5371c6 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -83,3 +83,33 @@ article h6 { font-size: 1em; margin-top: 1rem; } + +/* ── Right-panel TOC refinements ──────────────────────────── + * Adjust Furo's table-of-contents proportions for better + * readability. Inspired by Starlight defaults (Biome docs). + * Uses Furo CSS variable overrides where possible. + * ────────────────────────────────────────────────────────── */ + +/* TOC font sizes: items 75% → 81.25% (12→13px), + title 62.5% → 87.5% (10→14px) */ +:root { + --toc-font-size: var(--font-size--small--2); + --toc-title-font-size: var(--font-size--small); +} + +/* More generous line-height for wrapped TOC entries */ +.toc-tree { + line-height: 1.4; +} + +/* ── Body typography refinements ──────────────────────────── + * Improve paragraph readability with wider line-height and + * sharper text rendering. Furo already sets font-smoothing. + * ────────────────────────────────────────────────────────── */ +body { + text-rendering: optimizeLegibility; +} + +article { + line-height: 1.6; +} From f22b1f4fc25da536f0ceca0849cec80b1e99d3e8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 12 Mar 2026 17:21:43 -0600 Subject: [PATCH 29/50] docs(style[toc,content]): flexible TOC width with inner-panel padding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Furo's TOC had no breathing room from the viewport edge and long entries could overflow. Moving padding to the inner wrapper matches the Biome/Starlight pattern where the outer aside defines dimensions and an inner element controls content insets. what: - Add min-width: 18em on .toc-drawer to replace Furo's 15em - Move padding-right from .toc-drawer to .toc-sticky (inner panel) - Add flex: 1 on .content so it absorbs extra space on wide screens - Override responsive breakpoint to fully hide TOC at ≤82em --- docs/_static/css/custom.css | 38 +++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index 079b5371c6..b0f5ca0df5 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -102,6 +102,44 @@ article h6 { line-height: 1.4; } +/* ── Flexible right-panel TOC (inner-panel padding) ───────── + * Furo hardcodes .toc-drawer to width: 15em (SASS, compiled). + * min-width: 18em overrides it; long TOC entries wrap inside + * the box instead of blowing past the viewport. + * + * Padding lives on .toc-sticky (the inner panel), not on + * .toc-drawer (the outer aside). This matches Biome/Starlight + * where the aside defines dimensions and an inner wrapper + * (.right-sidebar-panel) controls content insets. The + * scrollbar sits naturally between content and viewport edge. + * + * Content area gets flex: 1 to absorb extra space on wide + * screens. At ≤82em Furo collapses the TOC to position: fixed; + * override right offset so the drawer fully hides off-screen. + * ────────────────────────────────────────────────────────── */ +.toc-drawer { + min-width: 18em; + flex-shrink: 0; + padding-right: 0; +} + +.toc-sticky { + padding-right: 1.5em; +} + +.content { + width: auto; + max-width: 46em; + flex: 1 1 46em; + padding: 0 2em; +} + +@media (max-width: 82em) { + .toc-drawer { + right: -18em; + } +} + /* ── Body typography refinements ──────────────────────────── * Improve paragraph readability with wider line-height and * sharper text rendering. Furo already sets font-smoothing. From 1c93acf89ba8a0a22aa9f758128d120e5f180c07 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 12 Mar 2026 17:23:58 -0600 Subject: [PATCH 30/50] docs(style[toc]): increase TOC font size from 81.25% to 87.5% why: Larger text improves readability for API pages with long method names. what: - Bump --toc-font-size from --font-size--small--2 to --font-size--small - Use body selector to match Furo's variable scoping (overrides :root) --- docs/_static/css/custom.css | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index b0f5ca0df5..7fe1581e83 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -90,11 +90,10 @@ article h6 { * Uses Furo CSS variable overrides where possible. * ────────────────────────────────────────────────────────── */ -/* TOC font sizes: items 75% → 81.25% (12→13px), - title 62.5% → 87.5% (10→14px) */ -:root { - --toc-font-size: var(--font-size--small--2); - --toc-title-font-size: var(--font-size--small); +/* TOC font sizes: override Furo defaults (75% → 87.5%) */ +body { + --toc-font-size: var(--font-size--small); /* 87.5% = 14px */ + --toc-title-font-size: var(--font-size--small); /* 87.5% = 14px */ } /* More generous line-height for wrapped TOC entries */ From a58026b64a6c9a169a600825c78f1b93ef431e3d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 12 Mar 2026 19:42:20 -0500 Subject: [PATCH 31/50] =?UTF-8?q?docs(style[headings]):=20refine=20heading?= =?UTF-8?q?=20hierarchy=20=E2=80=94=20scale,=20spacing,=20eyebrow=20labels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: H2/H3 size gap was too narrow (1.22×) to instantly convey hierarchy, H2 bottom border introduced visual noise inconsistent with the rest of the page, and headings were visually heavy with semibold weight. what: - Bump H2 from 1.4em to 1.6em (H2/H3 ratio now 1.39×) and increase top margin to 2.5rem for clear section breaks - Remove H2 border-bottom — rely on spacing + type scale only - Set all headings to weight 500 (medium) — size carries hierarchy - Add eyebrow style for H4-H6: uppercase, letter-spacing, muted color - Override eyebrow style in #history h4 to preserve changelog formatting - Move TOC variable overrides from body to :root --- docs/_static/css/custom.css | 41 +++++++++++++++++++++++++------------ 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index 7fe1581e83..577d8ce209 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -21,44 +21,56 @@ } /* ── Global heading refinements ───────────────────────────── - * Reduce Furo's default heading sizes (~30%) for better - * proportions. Furo uses bare h1-h6 selectors, so `article` - * prefix provides sufficient specificity to override. + * Biome-inspired scale: medium weight (500) throughout — size + * and spacing carry hierarchy, not boldness. H4-H6 add eyebrow + * treatment (uppercase, muted). `article` prefix overrides + * Furo's bare h1-h6 selectors. * ────────────────────────────────────────────────────────── */ article h1 { font-size: 1.8em; + font-weight: 500; margin-top: 1.5rem; margin-bottom: 0.75rem; } article h2 { - font-size: 1.4em; - margin-top: 1.5rem; + font-size: 1.6em; + font-weight: 500; + margin-top: 2.5rem; margin-bottom: 0.5rem; } article h3 { font-size: 1.15em; - font-weight: 600; + font-weight: 500; + margin-top: 1.5rem; margin-bottom: 0.375rem; } article h4 { - font-size: 1.05em; - font-weight: 600; + font-size: 0.85em; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-foreground-secondary); + margin-top: 1rem; margin-bottom: 0.25rem; } article h5 { - font-size: 1em; - font-weight: 600; + font-size: 0.8em; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-foreground-secondary); } article h6 { - font-size: 0.875em; - font-weight: 600; + font-size: 0.75em; + font-weight: 500; text-transform: uppercase; letter-spacing: 0.05em; + color: var(--color-foreground-secondary); } /* ── Changelog heading extras ─────────────────────────────── @@ -82,6 +94,9 @@ article h6 { #history h4 { font-size: 1em; margin-top: 1rem; + text-transform: none; + letter-spacing: normal; + color: inherit; } /* ── Right-panel TOC refinements ──────────────────────────── @@ -91,7 +106,7 @@ article h6 { * ────────────────────────────────────────────────────────── */ /* TOC font sizes: override Furo defaults (75% → 87.5%) */ -body { +:root { --toc-font-size: var(--font-size--small); /* 87.5% = 14px */ --toc-title-font-size: var(--font-size--small); /* 87.5% = 14px */ } From 9f13395cdcd32377c458e7230c816f8180ef79ce Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 13 Mar 2026 17:44:20 -0500 Subject: [PATCH 32/50] docs(README[pre-load hook]): remove dead bootstrap_env.py reference why: bootstrap_env.py no longer exists in the repository. what: - Remove link to bootstrap_env.py from pre-load hook section - Fix unclosed parenthesis in surrounding sentence --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index 1a1de04be9..c1a97d75a0 100644 --- a/README.md +++ b/README.md @@ -202,10 +202,8 @@ the CLI docs. # Pre-load hook -Run custom startup scripts (such as installing project dependencies +Run custom startup scripts (such as installing project dependencies) before loading tmux. See the -[bootstrap_env.py](https://github.com/tmux-python/tmuxp/blob/master/bootstrap_env.py) -and [before_script](http://tmuxp.git-pull.com/examples.html#bootstrap-project-before-launch) example From 9002a372b552d73e67c3c3c126ff905e61de50ef Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 13 Mar 2026 13:16:05 -0500 Subject: [PATCH 33/50] ci(docs): temporarily add docs-fonts branch to trigger why: Test font deployment before merging to master. what: - Add docs-fonts to push trigger branches (revert after verification) --- .github/workflows/docs.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index dd5f19f491..7c8185eeb3 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -4,6 +4,7 @@ on: push: branches: - master + - docs-fonts permissions: contents: read From 4d6ef275d17bccc7d839e81826e57dde96df7c49 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 13 Mar 2026 12:19:34 -0500 Subject: [PATCH 34/50] docs(fonts): self-host IBM Plex via Fontsource CDN why: Standardize on IBM Plex Sans / Mono across projects without committing ~227KB of binary font files to the repo. what: - Add sphinx_fonts extension that downloads fonts at build time, caches in ~/.cache/sphinx-fonts/, and generates @font-face CSS - Configure IBM Plex Sans (400/500/600/700) and IBM Plex Mono (400) with CSS variable overrides for Furo theme - Add actions/cache step in docs workflow for font cache persistence - Gitignore generated font assets in docs/_static/ --- .github/workflows/docs.yml | 9 +++ .gitignore | 4 ++ docs/_ext/sphinx_fonts.py | 144 +++++++++++++++++++++++++++++++++++++ docs/conf.py | 27 +++++++ 4 files changed, 184 insertions(+) create mode 100644 docs/_ext/sphinx_fonts.py diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 7c8185eeb3..dfdf570ee0 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -63,6 +63,15 @@ jobs: python -V uv run python -V + - name: Cache sphinx fonts + if: env.PUBLISH == 'true' + uses: actions/cache@v5 + with: + path: ~/.cache/sphinx-fonts + key: sphinx-fonts-${{ hashFiles('docs/conf.py') }} + restore-keys: | + sphinx-fonts- + - name: Build documentation if: env.PUBLISH == 'true' run: | diff --git a/.gitignore b/.gitignore index f82c6fba00..9dadb843bd 100644 --- a/.gitignore +++ b/.gitignore @@ -80,6 +80,10 @@ doc/_build/ # MonkeyType monkeytype.sqlite3 +# Generated by sphinx_fonts extension (downloaded at build time) +docs/_static/fonts/ +docs/_static/css/fonts.css + # Claude code **/CLAUDE.local.md **/CLAUDE.*.md diff --git a/docs/_ext/sphinx_fonts.py b/docs/_ext/sphinx_fonts.py new file mode 100644 index 0000000000..c9d0396a89 --- /dev/null +++ b/docs/_ext/sphinx_fonts.py @@ -0,0 +1,144 @@ +"""Sphinx extension for self-hosted fonts via Fontsource CDN. + +Downloads font files at build time, caches them locally, and generates +CSS with @font-face declarations and CSS variable overrides. +""" + +from __future__ import annotations + +import logging +import pathlib +import shutil +import typing as t +import urllib.error +import urllib.request + +if t.TYPE_CHECKING: + from sphinx.application import Sphinx + +logger = logging.getLogger(__name__) + +CDN_TEMPLATE = ( + "https://cdn.jsdelivr.net/npm/{package}@{version}" + "/files/{font_id}-{subset}-{weight}-{style}.woff2" +) + + +class SetupDict(t.TypedDict): + version: str + parallel_read_safe: bool + parallel_write_safe: bool + + +def _cache_dir() -> pathlib.Path: + return pathlib.Path.home() / ".cache" / "sphinx-fonts" + + +def _cdn_url( + package: str, + version: str, + font_id: str, + subset: str, + weight: int, + style: str, +) -> str: + return CDN_TEMPLATE.format( + package=package, + version=version, + font_id=font_id, + subset=subset, + weight=weight, + style=style, + ) + + +def _download_font(url: str, dest: pathlib.Path) -> bool: + if dest.exists(): + logger.debug("font cached: %s", dest.name) + return True + dest.parent.mkdir(parents=True, exist_ok=True) + try: + urllib.request.urlretrieve(url, dest) + logger.info("downloaded font: %s", dest.name) + except (urllib.error.URLError, OSError): + logger.warning("failed to download font: %s", url) + return False + return True + + +def _generate_css( + fonts: list[dict[str, t.Any]], + variables: dict[str, str], +) -> str: + lines: list[str] = [] + for font in fonts: + family = font["family"] + font_id = font["package"].split("/")[-1] + subset = font.get("subset", "latin") + for weight in font["weights"]: + for style in font["styles"]: + filename = f"{font_id}-{subset}-{weight}-{style}.woff2" + lines.append("@font-face {") + lines.append(f' font-family: "{family}";') + lines.append(f" font-style: {style};") + lines.append(f" font-weight: {weight};") + lines.append(" font-display: swap;") + lines.append(f' src: url("../fonts/{filename}") format("woff2");') + lines.append("}") + lines.append("") + + if variables: + lines.append(":root {") + for var, value in variables.items(): + lines.append(f" {var}: {value};") + lines.append("}") + lines.append("") + + return "\n".join(lines) + + +def _on_builder_inited(app: Sphinx) -> None: + if app.builder.format != "html": + return + + fonts: list[dict[str, t.Any]] = app.config.sphinx_fonts + variables: dict[str, str] = app.config.sphinx_font_css_variables + if not fonts: + return + + cache = _cache_dir() + static_dir = pathlib.Path(app.outdir) / "_static" + fonts_dir = static_dir / "fonts" + css_dir = static_dir / "css" + fonts_dir.mkdir(parents=True, exist_ok=True) + css_dir.mkdir(parents=True, exist_ok=True) + + for font in fonts: + font_id = font["package"].split("/")[-1] + version = font["version"] + package = font["package"] + subset = font.get("subset", "latin") + for weight in font["weights"]: + for style in font["styles"]: + filename = f"{font_id}-{subset}-{weight}-{style}.woff2" + cached = cache / filename + url = _cdn_url(package, version, font_id, subset, weight, style) + if _download_font(url, cached): + shutil.copy2(cached, fonts_dir / filename) + + css_content = _generate_css(fonts, variables) + (css_dir / "fonts.css").write_text(css_content, encoding="utf-8") + logger.info("generated fonts.css with %d font families", len(fonts)) + + app.add_css_file("css/fonts.css") + + +def setup(app: Sphinx) -> SetupDict: + app.add_config_value("sphinx_fonts", [], "html") + app.add_config_value("sphinx_font_css_variables", {}, "html") + app.connect("builder-inited", _on_builder_inited) + return { + "version": "1.0", + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/docs/conf.py b/docs/conf.py index 9a1f957e6f..799c672140 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -36,6 +36,7 @@ "sphinx.ext.napoleon", "sphinx.ext.linkcode", "aafig", + "sphinx_fonts", "argparse_exemplar", # Custom sphinx-argparse replacement "sphinx_inline_tabs", "sphinx_copybutton", @@ -146,6 +147,32 @@ aafig_format = {"latex": "pdf", "html": "gif"} aafig_default_options = {"scale": 0.75, "aspect": 0.5, "proportional": True} +# sphinx_fonts — self-hosted IBM Plex via Fontsource CDN +sphinx_fonts = [ + { + "family": "IBM Plex Sans", + "package": "@fontsource/ibm-plex-sans", + "version": "5.2.8", + "weights": [400, 500, 600, 700], + "styles": ["normal", "italic"], + "subset": "latin", + }, + { + "family": "IBM Plex Mono", + "package": "@fontsource/ibm-plex-mono", + "version": "5.2.7", + "weights": [400], + "styles": ["normal", "italic"], + "subset": "latin", + }, +] + +sphinx_font_css_variables = { + "--font-stack": '"IBM Plex Sans", -apple-system, BlinkMacSystemFont, sans-serif', + "--font-stack--monospace": '"IBM Plex Mono", SFMono-Regular, Menlo, Consolas, monospace', + "--font-stack--headings": "var(--font-stack)", +} + intersphinx_mapping = { "python": ("https://docs.python.org/", None), "libtmux": ("https://libtmux.git-pull.com/", None), From 3bca8c5fd27c6695155354ada7e703ca2fbc49a0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 13 Mar 2026 13:27:14 -0500 Subject: [PATCH 35/50] =?UTF-8?q?docs(fonts[css]):=20fix=20variable=20spec?= =?UTF-8?q?ificity=20=E2=80=94=20use=20body=20instead=20of=20:root?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Furo sets --font-stack on body, which overrides :root via direct declaration. Our fonts.css loaded but never rendered. what: - Change CSS variable selector from :root to body in _generate_css() - Same specificity + later source order ensures our override wins --- docs/_ext/sphinx_fonts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/_ext/sphinx_fonts.py b/docs/_ext/sphinx_fonts.py index c9d0396a89..7903f30cd2 100644 --- a/docs/_ext/sphinx_fonts.py +++ b/docs/_ext/sphinx_fonts.py @@ -88,7 +88,7 @@ def _generate_css( lines.append("") if variables: - lines.append(":root {") + lines.append("body {") for var, value in variables.items(): lines.append(f" {var}: {value};") lines.append("}") From a3ff18c35ee7bac7c3c05f04d01c3fd3940e241b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 13 Mar 2026 14:17:39 -0500 Subject: [PATCH 36/50] docs(fonts[preload]): add for critical font weights MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The browser doesn't discover font URLs until it parses fonts.css, which itself must wait for the HTML to load. This creates a waterfall (HTML → CSS → font download) that causes a visible Flash of Unstyled Text (FOUT) — the page renders with system fallback fonts, then swaps to IBM Plex once the fonts arrive. Preload hints in tell the browser to start downloading fonts immediately, in parallel with CSS parsing, so fonts arrive before first paint and the swap is invisible. what: - Add sphinx_font_preload config option to sphinx_fonts extension accepting (family, weight, style) tuples for selective preloading - Compute preload filenames in _on_builder_inited() and pass them to templates via html-page-context event handler - Emit tags in page.html template's extrahead block - Preload only 3 critical above-the-fold weights: Sans 400 (body), Sans 700 (headings), Mono 400 (code) — other variants load on demand - Rename layout.html → page.html and extend !page.html instead of !layout.html — Furo theme blocks layout.html inheritance (its layout.html is deliberately an error page) note: The original approach used Sphinx's add_css_file() for preload tags, but Sphinx appends ?v= query strings to those URLs. The @font-face declarations in fonts.css reference fonts without query strings, so the browser treated them as different resources — downloading each font twice and defeating the preload entirely. The template-based approach produces URLs without query strings, matching @font-face exactly: one download, zero FOUT. --- docs/_ext/sphinx_fonts.py | 24 ++++++++++++++++++++++ docs/_templates/{layout.html => page.html} | 5 ++++- docs/conf.py | 6 ++++++ 3 files changed, 34 insertions(+), 1 deletion(-) rename docs/_templates/{layout.html => page.html} (91%) diff --git a/docs/_ext/sphinx_fonts.py b/docs/_ext/sphinx_fonts.py index 7903f30cd2..586a83a042 100644 --- a/docs/_ext/sphinx_fonts.py +++ b/docs/_ext/sphinx_fonts.py @@ -130,13 +130,37 @@ def _on_builder_inited(app: Sphinx) -> None: (css_dir / "fonts.css").write_text(css_content, encoding="utf-8") logger.info("generated fonts.css with %d font families", len(fonts)) + preload_hrefs: list[str] = [] + preload_specs: list[tuple[str, int, str]] = app.config.sphinx_font_preload + for family_name, weight, style in preload_specs: + for font in fonts: + if font["family"] == family_name: + font_id = font["package"].split("/")[-1] + subset = font.get("subset", "latin") + filename = f"{font_id}-{subset}-{weight}-{style}.woff2" + preload_hrefs.append(filename) + break + app._font_preload_hrefs = preload_hrefs # type: ignore[attr-defined] + app.add_css_file("css/fonts.css") +def _on_html_page_context( + app: Sphinx, + pagename: str, + templatename: str, + context: dict[str, t.Any], + doctree: t.Any, +) -> None: + context["font_preload_hrefs"] = getattr(app, "_font_preload_hrefs", []) + + def setup(app: Sphinx) -> SetupDict: app.add_config_value("sphinx_fonts", [], "html") app.add_config_value("sphinx_font_css_variables", {}, "html") + app.add_config_value("sphinx_font_preload", [], "html") app.connect("builder-inited", _on_builder_inited) + app.connect("html-page-context", _on_html_page_context) return { "version": "1.0", "parallel_read_safe": True, diff --git a/docs/_templates/layout.html b/docs/_templates/page.html similarity index 91% rename from docs/_templates/layout.html rename to docs/_templates/page.html index 2943238cf7..c213992ac4 100644 --- a/docs/_templates/layout.html +++ b/docs/_templates/page.html @@ -1,6 +1,9 @@ -{% extends "!layout.html" %} +{% extends "!page.html" %} {%- block extrahead %} {{ super() }} + {%- for href in font_preload_hrefs|default([]) %} + + {%- endfor %} {%- if theme_show_meta_manifest_tag == true %} {% endif -%} diff --git a/docs/conf.py b/docs/conf.py index 799c672140..2d9c9f47a5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -167,6 +167,12 @@ }, ] +sphinx_font_preload = [ + ("IBM Plex Sans", 400, "normal"), # body text + ("IBM Plex Sans", 700, "normal"), # headings + ("IBM Plex Mono", 400, "normal"), # code blocks +] + sphinx_font_css_variables = { "--font-stack": '"IBM Plex Sans", -apple-system, BlinkMacSystemFont, sans-serif', "--font-stack--monospace": '"IBM Plex Mono", SFMono-Regular, Menlo, Consolas, monospace', From 409396127a0994af343a67b3641093600c3ecd48 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 13 Mar 2026 14:42:50 -0500 Subject: [PATCH 37/50] docs(fonts[css]): add kerning, ligatures, and code rendering overrides why: IBM Plex's OpenType features (kern, liga) weren't being leveraged, and code blocks inherited prose text-rendering that can break monospace grid alignment. what: - Add font-kerning, font-variant-ligatures, letter-spacing to body - Add optimizeSpeed + kerning/ligature/spacing resets for pre/code/kbd/samp --- docs/_static/css/custom.css | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index 577d8ce209..e53aa456f1 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -157,9 +157,31 @@ article h6 { /* ── Body typography refinements ──────────────────────────── * Improve paragraph readability with wider line-height and * sharper text rendering. Furo already sets font-smoothing. + * + * IBM Plex tracks slightly wide at default spacing; -0.01em + * tightens it to feel more natural (matches tony.sh/tony.nl). + * Kerning + ligatures polish AV/To pairs and fi/fl combos. * ────────────────────────────────────────────────────────── */ body { text-rendering: optimizeLegibility; + font-kerning: normal; + font-variant-ligatures: common-ligatures; + letter-spacing: -0.01em; +} + +/* ── Code block text rendering ──────────────────────────── + * Monospace needs fixed-width columns: disable kerning, + * ligatures, and letter-spacing that body sets for prose. + * optimizeSpeed skips heuristics that can shift the grid. + * ────────────────────────────────────────────────────────── */ +pre, +code, +kbd, +samp { + text-rendering: optimizeSpeed; + font-kerning: none; + font-variant-ligatures: none; + letter-spacing: normal; } article { From 98a4798117d9e1be116840483e2394ea4bdf552b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 13 Mar 2026 15:33:10 -0500 Subject: [PATCH 38/50] docs(images[cls]): prevent layout shift and add non-blocking loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Every on the docs site lacked dimension hints, causing Cumulative Layout Shift (CLS) on page load — content jumped as images rendered. External badge images (shields.io, Codecov) were especially slow, and the 447KB demo GIF blocked rendering on hard refresh. what: - Add :width:, :height:, :loading: lazy to image directives in index.md (888×589), cli/shell.md (878×109), developing.md (1030×605) - Add CSS aspect-ratio per image to reserve proportional space before load; override docutils inline height with height: auto !important so Furo's max-width: 100% scales without distortion - Add content-visibility: auto on all img for off-screen decode skip - Add CSS height: 20px for shields.io / badge.svg / codecov.io badges to prevent 0→20px shift while external images load - Add sidebar/brand.html template override with width="200" height="200" decoding="async" on logo (above-fold, no lazy loading) --- docs/_static/css/custom.css | 40 ++++++++++++++++++++++++++++++ docs/_templates/sidebar/brand.html | 18 ++++++++++++++ docs/cli/shell.md | 4 ++- docs/developing.md | 4 ++- docs/index.md | 5 ++-- 5 files changed, 67 insertions(+), 4 deletions(-) create mode 100644 docs/_templates/sidebar/brand.html diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index e53aa456f1..2f85829f84 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -187,3 +187,43 @@ samp { article { line-height: 1.6; } + +/* ── Image layout shift prevention ──────────────────────── + * Reserve space for images before they load. Furo already + * sets max-width: 100%; height: auto on img. We add + * content-visibility and badge-specific height to prevent CLS. + * ────────────────────────────────────────────────────────── */ +img { + content-visibility: auto; +} + +/* Docutils emits :width:/:height: as inline CSS (style="width: Xpx; + * height: Ypx;") rather than HTML attributes. When Furo's + * max-width: 100% constrains width below the declared value, + * the fixed height causes distortion. height: auto + aspect-ratio + * lets the browser compute the correct height from the intrinsic + * ratio once loaded; before load, aspect-ratio reserves space + * at the intended proportion — preventing both CLS and distortion. */ +article img[loading="lazy"] { + height: auto !important; +} + +/* Per-image aspect ratios for CLS reservation before load */ +img[src*="tmuxp-demo"] { + aspect-ratio: 888 / 589; +} + +img[src*="tmuxp-shell"] { + aspect-ratio: 878 / 109; +} + +img[src*="tmuxp-dev-screenshot"] { + aspect-ratio: 1030 / 605; +} + +img[src*="shields.io"], +img[src*="badge.svg"], +img[src*="codecov.io"] { + height: 20px; + width: auto; +} diff --git a/docs/_templates/sidebar/brand.html b/docs/_templates/sidebar/brand.html new file mode 100644 index 0000000000..7fe241c009 --- /dev/null +++ b/docs/_templates/sidebar/brand.html @@ -0,0 +1,18 @@ + diff --git a/docs/cli/shell.md b/docs/cli/shell.md index de47b9b9e1..586c305e54 100644 --- a/docs/cli/shell.md +++ b/docs/cli/shell.md @@ -23,7 +23,9 @@ $ tmuxp shell -c 'python code' ``` ```{image} ../_static/tmuxp-shell.gif -:width: 100% +:width: 878 +:height: 109 +:loading: lazy ``` ## Interactive usage diff --git a/docs/developing.md b/docs/developing.md index 9f2e221cfd..1b909363df 100644 --- a/docs/developing.md +++ b/docs/developing.md @@ -318,8 +318,10 @@ $ make SPHINXBUILD='uv run sphinx-build' watch ## tmuxp developer config ```{image} _static/tmuxp-dev-screenshot.png +:width: 1030 +:height: 605 :align: center - +:loading: lazy ``` After you {ref}`install-dev-env`, when inside the tmuxp checkout: diff --git a/docs/index.md b/docs/index.md index 14b69dedb0..fd8a21575f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,8 +5,9 @@ ``` ```{image} _static/tmuxp-demo.gif -:width: 100% - +:width: 888 +:height: 589 +:loading: lazy ``` # Freeze a tmux session From ca64719b6a51f13bec344fe75c25754fe8352dd5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 13 Mar 2026 16:26:13 -0500 Subject: [PATCH 39/50] docs(nav[spa]): add SPA-like navigation to avoid full page reloads why: Every page navigation re-downloads and re-parses 8 CSS files, 7 JS files, 3 fonts, re-renders the sidebar, SVG icons, and the entire layout. Only the article content, right-panel TOC, and active sidebar link actually change between pages. what: - Create docs/_static/js/spa-nav.js (~170 lines, vanilla JS, no deps) - Intercept internal link clicks via event delegation on document - Fetch target page, parse with DOMParser, swap three DOM regions: .article-container, .sidebar-tree, .toc-drawer - Preserve sidebar scroll position, theme state, all CSS/JS/fonts - Replicate Furo's cycleThemeOnce for swapped .content-icon-container - Inject copy buttons on new code blocks via cloneNode from template (ClipboardJS event delegation picks up new .copybtn elements) - Minimal scrollspy (~25 lines) replaces Gumshoe for swapped TOC - AbortController cancels in-flight fetches on rapid navigation - history.pushState/popstate for back/forward browser navigation - Debounced prefetch on hover (65ms) for near-instant transitions - Progressive enhancement: no-op if fetch/DOMParser/pushState absent - Skip interception for search.html, genindex, external links, modifier-key clicks, download attrs, and #sidebar-projects links - Fallback to window.location.href on network error or missing DOM - Register script in conf.py setup() with loading_method="defer" --- docs/_static/js/spa-nav.js | 228 +++++++++++++++++++++++++++++++++++++ docs/conf.py | 1 + 2 files changed, 229 insertions(+) create mode 100644 docs/_static/js/spa-nav.js diff --git a/docs/_static/js/spa-nav.js b/docs/_static/js/spa-nav.js new file mode 100644 index 0000000000..fa7fd2ed6f --- /dev/null +++ b/docs/_static/js/spa-nav.js @@ -0,0 +1,228 @@ +/** + * SPA-like navigation for Sphinx/Furo docs. + * + * Intercepts internal link clicks and swaps only the content that changes + * (article, sidebar nav tree, TOC drawer), preserving sidebar scroll + * position, theme state, and avoiding full-page reloads. + * + * Progressive enhancement: no-op when fetch/DOMParser/pushState unavailable. + */ +(function () { + "use strict"; + + if (!window.fetch || !window.DOMParser || !window.history?.pushState) return; + + // --- Theme toggle (replicates Furo's cycleThemeOnce) --- + + function cycleTheme() { + var current = localStorage.getItem("theme") || "auto"; + var prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + var next; + if (current === "auto") next = prefersDark ? "light" : "dark"; + else if (current === "dark") next = prefersDark ? "auto" : "light"; + else next = prefersDark ? "dark" : "auto"; + document.body.dataset.theme = next; + localStorage.setItem("theme", next); + } + + // --- Copy button injection --- + + var copyBtnTemplate = null; + + function captureCopyIcon() { + var btn = document.querySelector(".copybtn"); + if (btn) copyBtnTemplate = btn.cloneNode(true); + } + + function addCopyButtons() { + if (!copyBtnTemplate) captureCopyIcon(); + if (!copyBtnTemplate) return; + var cells = document.querySelectorAll("div.highlight pre"); + cells.forEach(function (cell, i) { + cell.id = "codecell" + i; + var next = cell.nextElementSibling; + if (next && next.classList.contains("copybtn")) { + next.setAttribute("data-clipboard-target", "#codecell" + i); + } else { + var btn = copyBtnTemplate.cloneNode(true); + btn.setAttribute("data-clipboard-target", "#codecell" + i); + cell.insertAdjacentElement("afterend", btn); + } + }); + } + + // --- Minimal scrollspy --- + + var scrollCleanup = null; + + function initScrollSpy() { + if (scrollCleanup) scrollCleanup(); + scrollCleanup = null; + + var links = document.querySelectorAll(".toc-tree a"); + if (!links.length) return; + + var entries = []; + links.forEach(function (a) { + var id = (a.getAttribute("href") || "").split("#")[1]; + var el = id && document.getElementById(id); + var li = a.closest("li"); + if (el && li) entries.push({ el: el, li: li }); + }); + if (!entries.length) return; + + function update() { + var offset = + parseFloat(getComputedStyle(document.documentElement).fontSize) * 4; + var active = null; + for (var i = entries.length - 1; i >= 0; i--) { + if (entries[i].el.getBoundingClientRect().top <= offset) { + active = entries[i]; + break; + } + } + entries.forEach(function (e) { + e.li.classList.remove("scroll-current"); + }); + if (active) active.li.classList.add("scroll-current"); + } + + window.addEventListener("scroll", update, { passive: true }); + update(); + scrollCleanup = function () { + window.removeEventListener("scroll", update); + }; + } + + // --- Link interception --- + + function shouldIntercept(link, e) { + if (e.defaultPrevented || e.button !== 0) return false; + if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return false; + if (link.origin !== location.origin) return false; + if (link.target && link.target !== "_self") return false; + if (link.hasAttribute("download")) return false; + + var path = link.pathname; + if (!path.endsWith(".html") && !path.endsWith("/")) return false; + + var base = path.split("/").pop() || ""; + if ( + base === "search.html" || + base === "genindex.html" || + base === "py-modindex.html" + ) + return false; + + if (link.closest("#sidebar-projects")) return false; + if (link.pathname === location.pathname && link.hash) return false; + + return true; + } + + // --- DOM swap --- + + function swap(doc) { + [".article-container", ".sidebar-tree", ".toc-drawer"].forEach( + function (sel) { + var fresh = doc.querySelector(sel); + var stale = document.querySelector(sel); + if (fresh && stale) stale.replaceWith(fresh); + }, + ); + var title = doc.querySelector("title"); + if (title) document.title = title.textContent || ""; + } + + function reinit() { + addCopyButtons(); + initScrollSpy(); + var btn = document.querySelector(".content-icon-container .theme-toggle"); + if (btn) btn.addEventListener("click", cycleTheme); + } + + // --- Navigation --- + + var currentCtrl = null; + + async function navigate(url, isPop) { + if (currentCtrl) currentCtrl.abort(); + var ctrl = new AbortController(); + currentCtrl = ctrl; + + try { + var resp = await fetch(url, { signal: ctrl.signal }); + if (!resp.ok) throw new Error(resp.status); + + var html = await resp.text(); + var doc = new DOMParser().parseFromString(html, "text/html"); + + if (!doc.querySelector(".article-container")) + throw new Error("no article"); + + swap(doc); + + if (!isPop) history.pushState({ spa: true }, "", url); + + if (!isPop) { + var hash = new URL(url, location.href).hash; + if (hash) { + var el = document.querySelector(hash); + if (el) el.scrollIntoView(); + } else { + window.scrollTo(0, 0); + } + } + + reinit(); + } catch (err) { + if (err.name === "AbortError") return; + window.location.href = url; + } finally { + if (currentCtrl === ctrl) currentCtrl = null; + } + } + + // --- Events --- + + document.addEventListener("click", function (e) { + var link = e.target.closest("a[href]"); + if (link && shouldIntercept(link, e)) { + e.preventDefault(); + navigate(link.href, false); + } + }); + + history.replaceState({ spa: true }, ""); + + window.addEventListener("popstate", function (e) { + if (e.state && e.state.spa) navigate(location.href, true); + }); + + // --- Hover prefetch --- + + var prefetchTimer = null; + + document.addEventListener("mouseover", function (e) { + var link = e.target.closest("a[href]"); + if (!link || link.origin !== location.origin) return; + if (!link.pathname.endsWith(".html") && !link.pathname.endsWith("/")) + return; + + clearTimeout(prefetchTimer); + prefetchTimer = setTimeout(function () { + fetch(link.href, { priority: "low" }).catch(function () {}); + }, 65); + }); + + document.addEventListener("mouseout", function (e) { + if (e.target.closest("a[href]")) clearTimeout(prefetchTimer); + }); + + // --- Init --- + + // Copy buttons are injected by copybutton.js on DOMContentLoaded. + // This defer script runs before DOMContentLoaded, so our handler + // fires after copybutton's handler (registration order preserved). + document.addEventListener("DOMContentLoaded", captureCopyIcon); +})(); diff --git a/docs/conf.py b/docs/conf.py index 2d9c9f47a5..d77b72f3c5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -265,4 +265,5 @@ def remove_tabs_js(app: Sphinx, exc: Exception) -> None: def setup(app: Sphinx) -> None: """Sphinx setup hook.""" + app.add_js_file("js/spa-nav.js", loading_method="defer") app.connect("build-finished", remove_tabs_js) From fb322d9bed4f762c8922399fdeb9962d1c4464d6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 13 Mar 2026 18:21:40 -0500 Subject: [PATCH 40/50] docs(fonts[fallback]): add fallback font metrics to eliminate FOUT reflow why: font-display: swap causes visible text reflow when IBM Plex loads because the system fallback (Arial) has different character dimensions. what: - Add sphinx_font_fallbacks config with size-adjust/ascent/descent overrides - Generate fallback @font-face declarations in fonts.css (Capsize formula) - Include fallback families in --font-stack CSS variables --- docs/_ext/sphinx_fonts.py | 17 ++++++++++++++++- docs/conf.py | 23 +++++++++++++++++++++-- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/docs/_ext/sphinx_fonts.py b/docs/_ext/sphinx_fonts.py index 586a83a042..5a4fca47b2 100644 --- a/docs/_ext/sphinx_fonts.py +++ b/docs/_ext/sphinx_fonts.py @@ -69,6 +69,7 @@ def _download_font(url: str, dest: pathlib.Path) -> bool: def _generate_css( fonts: list[dict[str, t.Any]], variables: dict[str, str], + fallbacks: list[dict[str, str]] | None = None, ) -> str: lines: list[str] = [] for font in fonts: @@ -87,6 +88,18 @@ def _generate_css( lines.append("}") lines.append("") + if fallbacks: + for fb in fallbacks: + lines.append("@font-face {") + lines.append(f' font-family: "{fb["family"]}";') + lines.append(f" src: {fb['src']};") + lines.append(f" size-adjust: {fb['size_adjust']};") + lines.append(f" ascent-override: {fb['ascent_override']};") + lines.append(f" descent-override: {fb['descent_override']};") + lines.append(f" line-gap-override: {fb['line_gap_override']};") + lines.append("}") + lines.append("") + if variables: lines.append("body {") for var, value in variables.items(): @@ -126,7 +139,8 @@ def _on_builder_inited(app: Sphinx) -> None: if _download_font(url, cached): shutil.copy2(cached, fonts_dir / filename) - css_content = _generate_css(fonts, variables) + fallbacks: list[dict[str, str]] = app.config.sphinx_font_fallbacks + css_content = _generate_css(fonts, variables, fallbacks) (css_dir / "fonts.css").write_text(css_content, encoding="utf-8") logger.info("generated fonts.css with %d font families", len(fonts)) @@ -157,6 +171,7 @@ def _on_html_page_context( def setup(app: Sphinx) -> SetupDict: app.add_config_value("sphinx_fonts", [], "html") + app.add_config_value("sphinx_font_fallbacks", [], "html") app.add_config_value("sphinx_font_css_variables", {}, "html") app.add_config_value("sphinx_font_preload", [], "html") app.connect("builder-inited", _on_builder_inited) diff --git a/docs/conf.py b/docs/conf.py index d77b72f3c5..b401540b0a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -173,9 +173,28 @@ ("IBM Plex Mono", 400, "normal"), # code blocks ] +sphinx_font_fallbacks = [ + { + "family": "IBM Plex Sans Fallback", + "src": 'local("Arial"), local("Helvetica Neue"), local("Helvetica")', + "size_adjust": "110.6%", + "ascent_override": "92.7%", + "descent_override": "24.9%", + "line_gap_override": "0%", + }, + { + "family": "IBM Plex Mono Fallback", + "src": 'local("Courier New"), local("Courier")', + "size_adjust": "100%", + "ascent_override": "102.5%", + "descent_override": "27.5%", + "line_gap_override": "0%", + }, +] + sphinx_font_css_variables = { - "--font-stack": '"IBM Plex Sans", -apple-system, BlinkMacSystemFont, sans-serif', - "--font-stack--monospace": '"IBM Plex Mono", SFMono-Regular, Menlo, Consolas, monospace', + "--font-stack": '"IBM Plex Sans", "IBM Plex Sans Fallback", -apple-system, BlinkMacSystemFont, sans-serif', + "--font-stack--monospace": '"IBM Plex Mono", "IBM Plex Mono Fallback", SFMono-Regular, Menlo, Consolas, monospace', "--font-stack--headings": "var(--font-stack)", } From dadbe0272efcc9d127333f69c2db03c9d1b959d8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 13 Mar 2026 18:22:20 -0500 Subject: [PATCH 41/50] docs(images[badges]): add placeholder sizing for external badge images why: badges flash in from zero width because width: auto computes to 0 before the image loads, causing cumulative layout shift. what: - Add min-width: 60px to reserve approximate badge width - Add border-radius: 3px matching shields.io badge shape - Add background placeholder that disappears behind loaded badge --- docs/_static/css/custom.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index 2f85829f84..7c7d89d9f5 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -226,4 +226,7 @@ img[src*="badge.svg"], img[src*="codecov.io"] { height: 20px; width: auto; + min-width: 60px; + border-radius: 3px; + background: var(--color-background-secondary); } From 7f113a81fa780a31efbed951fca60e57c50b14c4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 13 Mar 2026 18:22:37 -0500 Subject: [PATCH 42/50] docs(sidebar[projects]): prevent active link flash with visibility gate why: all links render with class="current" then JS replaces the hostname-matching link with a bold span, causing a visible reflow when IBM Plex fonts load with different metrics than the fallback. what: - Remove misleading class="current" from all project links - Hide #sidebar-projects until JS resolves active state (.ready class) - Use textContent instead of innerHTML for safer DOM manipulation --- docs/_static/css/custom.css | 4 ++++ docs/_templates/sidebar/projects.html | 21 +++++++++++---------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index 7c7d89d9f5..c979ecbd60 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -16,6 +16,10 @@ margin-right: calc(var(--sidebar-item-spacing-horizontal) / 2.5); } +#sidebar-projects:not(.ready) { + visibility: hidden; +} + .sidebar-tree .active { font-weight: bold; } diff --git a/docs/_templates/sidebar/projects.html b/docs/_templates/sidebar/projects.html index 97420c1adf..0c182a2b33 100644 --- a/docs/_templates/sidebar/projects.html +++ b/docs/_templates/sidebar/projects.html @@ -7,24 +7,24 @@

vcs-python - vcspull + vcspull (libvcs), g

tmux-python - tmuxp + tmuxp (libtmux)

cihai - unihan-etl + unihan-etl (db) - cihai + cihai (cli)

@@ -32,38 +32,39 @@

django - django-slugify-processor + django-slugify-processor - django-docutils + django-docutils

docs + tests - gp-libs + gp-libs

web - social-embed + social-embed

From b733808998a51778b14a8a60e870e18afebddf09 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 13 Mar 2026 18:22:42 -0500 Subject: [PATCH 43/50] docs(nav[spa]): wrap DOM swap in View Transitions API for smooth crossfade why: SPA navigation instantly replaces DOM content, causing a jarring visual jump between pages instead of a smooth transition. what: - Wrap swap+reinit in document.startViewTransition() when available - Add 150ms crossfade animation via ::view-transition pseudo-elements - Progressive enhancement: unsupported browsers get instant swap --- docs/_static/css/custom.css | 10 ++++++++++ docs/_static/js/spa-nav.js | 32 ++++++++++++++++++++------------ 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index c979ecbd60..1e2c5327af 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -197,6 +197,16 @@ article { * sets max-width: 100%; height: auto on img. We add * content-visibility and badge-specific height to prevent CLS. * ────────────────────────────────────────────────────────── */ + +/* ── View Transitions (SPA navigation) ──────────────────── + * Crossfade between pages during SPA navigation. + * Browsers without View Transitions API get instant swap. + * ────────────────────────────────────────────────────────── */ +::view-transition-old(root), +::view-transition-new(root) { + animation-duration: 150ms; +} + img { content-visibility: auto; } diff --git a/docs/_static/js/spa-nav.js b/docs/_static/js/spa-nav.js index fa7fd2ed6f..e00e521ab8 100644 --- a/docs/_static/js/spa-nav.js +++ b/docs/_static/js/spa-nav.js @@ -160,21 +160,29 @@ if (!doc.querySelector(".article-container")) throw new Error("no article"); - swap(doc); + var applySwap = function () { + swap(doc); + + if (!isPop) history.pushState({ spa: true }, "", url); + + if (!isPop) { + var hash = new URL(url, location.href).hash; + if (hash) { + var el = document.querySelector(hash); + if (el) el.scrollIntoView(); + } else { + window.scrollTo(0, 0); + } + } - if (!isPop) history.pushState({ spa: true }, "", url); + reinit(); + }; - if (!isPop) { - var hash = new URL(url, location.href).hash; - if (hash) { - var el = document.querySelector(hash); - if (el) el.scrollIntoView(); - } else { - window.scrollTo(0, 0); - } + if (document.startViewTransition) { + document.startViewTransition(applySwap); + } else { + applySwap(); } - - reinit(); } catch (err) { if (err.name === "AbortError") return; window.location.href = url; From 107f104ff688e57a0de31bf51426638db715c31b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 04:45:44 -0500 Subject: [PATCH 44/50] docs(css[structure]): move view transitions section after image rules why: The view transitions block was inserted between the "Image layout shift prevention" section header and its rules, orphaning the comment. what: - Move view transitions comment + rules to end of file - Keep image section header contiguous with its img/badge rules --- docs/_static/css/custom.css | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index 1e2c5327af..ed6640c746 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -198,15 +198,6 @@ article { * content-visibility and badge-specific height to prevent CLS. * ────────────────────────────────────────────────────────── */ -/* ── View Transitions (SPA navigation) ──────────────────── - * Crossfade between pages during SPA navigation. - * Browsers without View Transitions API get instant swap. - * ────────────────────────────────────────────────────────── */ -::view-transition-old(root), -::view-transition-new(root) { - animation-duration: 150ms; -} - img { content-visibility: auto; } @@ -244,3 +235,12 @@ img[src*="codecov.io"] { border-radius: 3px; background: var(--color-background-secondary); } + +/* ── View Transitions (SPA navigation) ──────────────────── + * Crossfade between pages during SPA navigation. + * Browsers without View Transitions API get instant swap. + * ────────────────────────────────────────────────────────── */ +::view-transition-old(root), +::view-transition-new(root) { + animation-duration: 150ms; +} From a31731aa1531e64a1df6be10f7a5b8b90d4f53b1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 04:45:48 -0500 Subject: [PATCH 45/50] docs(images[about]): add lazy loading to tao-tmux-screenshot why: Image is below the fold; lazy loading defers fetch until needed. what: - Add :loading: lazy to the figure directive in about_tmux.md --- docs/about_tmux.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/about_tmux.md b/docs/about_tmux.md index 5f10e2275e..2db7b3c4d7 100644 --- a/docs/about_tmux.md +++ b/docs/about_tmux.md @@ -5,6 +5,7 @@ :::{figure} /\_static/tao-tmux-screenshot.png :scale: 60% :align: center +:loading: lazy ISC-licensed terminal multiplexer. From 6564409f9640cb4bcab4c545daa466a6ce3f6b3c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 05:26:46 -0500 Subject: [PATCH 46/50] docs(fonts[loading]): switch to font-display block with inline CSS why: font-display swap causes visible text reflow (FOUT). Matching the tony.nl/cv approach: block rendering until preloaded fonts arrive, and inline the @font-face CSS to eliminate the extra fonts.css request. what: - Change font-display from swap to block - Move @font-face CSS from external fonts.css to inline + {%- endif %} {%- if theme_show_meta_manifest_tag == true %} {% endif -%} From 9c0caa15e277473c0a576c84e99211bda0c82a99 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 06:22:37 -0500 Subject: [PATCH 47/50] Revert "ci(docs): temporarily add docs-fonts branch to trigger" --- .github/workflows/docs.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index dfdf570ee0..d9dbf6716b 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -4,7 +4,6 @@ on: push: branches: - master - - docs-fonts permissions: contents: read From b8500216172866e9c7bbcd4d3ab6001baa7d3aac Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 16:03:44 -0500 Subject: [PATCH 48/50] test(docs[sphinx_fonts]) add tests and fix download failure bugs (#1023) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: sphinx_fonts.py had zero test coverage, and two latent bugs were discovered during test development — font-face entries emitted for failed downloads, and partial files poisoning the cache. what: Test suite (21 functions, 545 lines) - _cache_dir: path construction - _cdn_url: parametrized URL formatting, template structure - _download_font: cached hit, success, URLError, OSError, partial cleanup - _on_builder_inited: non-html skip, empty fonts, font processing, download failure skip, explicit subset, preload match/no-match, fallbacks and CSS variables - _on_html_page_context: with/without app attributes - setup: return metadata, config values, event connections Bug fixes in sphinx_fonts.py - Move font_faces.append() inside if _download_font() block to skip font-face entries when download fails (was emitting CSS pointing to missing files) - Add dest.unlink() in except block to remove partial .woff2 files left by interrupted downloads (was poisoning cache) Docstrings - Add docstrings to SetupDict and setup() for ruff D101/D103 --- docs/_ext/sphinx_fonts.py | 21 +- tests/docs/_ext/test_sphinx_fonts.py | 545 +++++++++++++++++++++++++++ 2 files changed, 558 insertions(+), 8 deletions(-) create mode 100644 tests/docs/_ext/test_sphinx_fonts.py diff --git a/docs/_ext/sphinx_fonts.py b/docs/_ext/sphinx_fonts.py index b03362e921..e8d2a692ae 100644 --- a/docs/_ext/sphinx_fonts.py +++ b/docs/_ext/sphinx_fonts.py @@ -25,6 +25,8 @@ class SetupDict(t.TypedDict): + """Return type for Sphinx extension setup().""" + version: str parallel_read_safe: bool parallel_write_safe: bool @@ -61,6 +63,8 @@ def _download_font(url: str, dest: pathlib.Path) -> bool: urllib.request.urlretrieve(url, dest) logger.info("downloaded font: %s", dest.name) except (urllib.error.URLError, OSError): + if dest.exists(): + dest.unlink() logger.warning("failed to download font: %s", url) return False return True @@ -93,14 +97,14 @@ def _on_builder_inited(app: Sphinx) -> None: url = _cdn_url(package, version, font_id, subset, weight, style) if _download_font(url, cached): shutil.copy2(cached, fonts_dir / filename) - font_faces.append( - { - "family": font["family"], - "style": style, - "weight": str(weight), - "filename": filename, - } - ) + font_faces.append( + { + "family": font["family"], + "style": style, + "weight": str(weight), + "filename": filename, + } + ) preload_hrefs: list[str] = [] preload_specs: list[tuple[str, int, str]] = app.config.sphinx_font_preload @@ -135,6 +139,7 @@ def _on_html_page_context( def setup(app: Sphinx) -> SetupDict: + """Register config values, events, and return extension metadata.""" app.add_config_value("sphinx_fonts", [], "html") app.add_config_value("sphinx_font_fallbacks", [], "html") app.add_config_value("sphinx_font_css_variables", {}, "html") diff --git a/tests/docs/_ext/test_sphinx_fonts.py b/tests/docs/_ext/test_sphinx_fonts.py new file mode 100644 index 0000000000..22f546a2e1 --- /dev/null +++ b/tests/docs/_ext/test_sphinx_fonts.py @@ -0,0 +1,545 @@ +"""Tests for sphinx_fonts Sphinx extension.""" + +from __future__ import annotations + +import logging +import pathlib +import types +import typing as t +import urllib.error + +import pytest +import sphinx_fonts + +# --- _cache_dir tests --- + + +def test_cache_dir_returns_home_cache_path() -> None: + """_cache_dir returns ~/.cache/sphinx-fonts.""" + result = sphinx_fonts._cache_dir() + assert result == pathlib.Path.home() / ".cache" / "sphinx-fonts" + + +# --- _cdn_url tests --- + + +class CdnUrlFixture(t.NamedTuple): + """Test fixture for CDN URL generation.""" + + test_id: str + package: str + version: str + font_id: str + subset: str + weight: int + style: str + expected_url: str + + +CDN_URL_FIXTURES: list[CdnUrlFixture] = [ + CdnUrlFixture( + test_id="normal_weight", + package="@fontsource/open-sans", + version="5.2.5", + font_id="open-sans", + subset="latin", + weight=400, + style="normal", + expected_url=( + "https://cdn.jsdelivr.net/npm/@fontsource/open-sans@5.2.5" + "/files/open-sans-latin-400-normal.woff2" + ), + ), + CdnUrlFixture( + test_id="bold_italic", + package="@fontsource/roboto", + version="5.0.0", + font_id="roboto", + subset="latin-ext", + weight=700, + style="italic", + expected_url=( + "https://cdn.jsdelivr.net/npm/@fontsource/roboto@5.0.0" + "/files/roboto-latin-ext-700-italic.woff2" + ), + ), +] + + +@pytest.mark.parametrize( + list(CdnUrlFixture._fields), + CDN_URL_FIXTURES, + ids=[f.test_id for f in CDN_URL_FIXTURES], +) +def test_cdn_url( + test_id: str, + package: str, + version: str, + font_id: str, + subset: str, + weight: int, + style: str, + expected_url: str, +) -> None: + """_cdn_url formats the CDN URL template correctly.""" + result = sphinx_fonts._cdn_url(package, version, font_id, subset, weight, style) + assert result == expected_url + + +def test_cdn_url_matches_template() -> None: + """_cdn_url produces URLs matching CDN_TEMPLATE structure.""" + url = sphinx_fonts._cdn_url( + "@fontsource/inter", "5.1.0", "inter", "latin", 400, "normal" + ) + assert url.startswith("https://cdn.jsdelivr.net/npm/") + assert "@fontsource/inter@5.1.0" in url + assert url.endswith(".woff2") + + +# --- _download_font tests --- + + +def test_download_font_cached( + tmp_path: pathlib.Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """_download_font returns True and logs debug when file exists.""" + dest = tmp_path / "font.woff2" + dest.write_bytes(b"cached-data") + + with caplog.at_level(logging.DEBUG, logger="sphinx_fonts"): + result = sphinx_fonts._download_font("https://example.com/font.woff2", dest) + + assert result is True + debug_records = [r for r in caplog.records if r.levelno == logging.DEBUG] + assert any("cached" in r.message for r in debug_records) + + +def test_download_font_success( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """_download_font downloads and returns True on success.""" + dest = tmp_path / "subdir" / "font.woff2" + + def fake_urlretrieve(url: str, filename: t.Any) -> tuple[str, t.Any]: + pathlib.Path(filename).write_bytes(b"font-data") + return (str(filename), None) + + monkeypatch.setattr("sphinx_fonts.urllib.request.urlretrieve", fake_urlretrieve) + + with caplog.at_level(logging.INFO, logger="sphinx_fonts"): + result = sphinx_fonts._download_font("https://example.com/font.woff2", dest) + + assert result is True + info_records = [r for r in caplog.records if r.levelno == logging.INFO] + assert any("downloaded" in r.message for r in info_records) + + +def test_download_font_url_error( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """_download_font returns False and warns on URLError.""" + dest = tmp_path / "font.woff2" + + msg = "network error" + + def fake_urlretrieve(url: str, filename: t.Any) -> t.NoReturn: + raise urllib.error.URLError(msg) + + monkeypatch.setattr("sphinx_fonts.urllib.request.urlretrieve", fake_urlretrieve) + + with caplog.at_level(logging.WARNING, logger="sphinx_fonts"): + result = sphinx_fonts._download_font("https://example.com/font.woff2", dest) + + assert result is False + warning_records = [r for r in caplog.records if r.levelno == logging.WARNING] + assert any("failed" in r.message for r in warning_records) + + +def test_download_font_os_error( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """_download_font returns False and warns on OSError.""" + dest = tmp_path / "font.woff2" + + msg = "disk full" + + def fake_urlretrieve(url: str, filename: t.Any) -> t.NoReturn: + raise OSError(msg) + + monkeypatch.setattr("sphinx_fonts.urllib.request.urlretrieve", fake_urlretrieve) + + with caplog.at_level(logging.WARNING, logger="sphinx_fonts"): + result = sphinx_fonts._download_font("https://example.com/font.woff2", dest) + + assert result is False + warning_records = [r for r in caplog.records if r.levelno == logging.WARNING] + assert any("failed" in r.message for r in warning_records) + + +def test_download_font_partial_file_cleanup( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """_download_font removes partial file on failure.""" + dest = tmp_path / "cache" / "partial.woff2" + + msg = "disk full" + + def fake_urlretrieve(url: str, filename: t.Any) -> t.NoReturn: + pathlib.Path(filename).write_bytes(b"partial") + raise OSError(msg) + + monkeypatch.setattr("sphinx_fonts.urllib.request.urlretrieve", fake_urlretrieve) + + result = sphinx_fonts._download_font("https://example.com/font.woff2", dest) + + assert result is False + assert not dest.exists() + + +# --- _on_builder_inited tests --- + + +def _make_app( + tmp_path: pathlib.Path, + *, + builder_format: str = "html", + fonts: list[dict[str, t.Any]] | None = None, + preload: list[tuple[str, int, str]] | None = None, + fallbacks: list[dict[str, str]] | None = None, + variables: dict[str, str] | None = None, +) -> types.SimpleNamespace: + """Create a fake Sphinx app namespace for testing.""" + config = types.SimpleNamespace( + sphinx_fonts=fonts if fonts is not None else [], + sphinx_font_preload=preload if preload is not None else [], + sphinx_font_fallbacks=fallbacks if fallbacks is not None else [], + sphinx_font_css_variables=variables if variables is not None else {}, + ) + builder = types.SimpleNamespace(format=builder_format) + return types.SimpleNamespace( + builder=builder, + config=config, + outdir=str(tmp_path / "output"), + ) + + +def test_on_builder_inited_non_html(tmp_path: pathlib.Path) -> None: + """_on_builder_inited returns early for non-HTML builders.""" + app = _make_app(tmp_path, builder_format="latex") + sphinx_fonts._on_builder_inited(app) + assert not hasattr(app, "_font_faces") + + +def test_on_builder_inited_empty_fonts(tmp_path: pathlib.Path) -> None: + """_on_builder_inited returns early when no fonts configured.""" + app = _make_app(tmp_path, fonts=[]) + sphinx_fonts._on_builder_inited(app) + assert not hasattr(app, "_font_faces") + + +def test_on_builder_inited_with_fonts( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """_on_builder_inited processes fonts and stores results on app.""" + monkeypatch.setattr("sphinx_fonts._cache_dir", lambda: tmp_path / "cache") + + fonts = [ + { + "package": "@fontsource/open-sans", + "version": "5.2.5", + "family": "Open Sans", + "weights": [400, 700], + "styles": ["normal"], + }, + ] + app = _make_app(tmp_path, fonts=fonts) + + cache = tmp_path / "cache" + cache.mkdir(parents=True) + for weight in [400, 700]: + (cache / f"open-sans-latin-{weight}-normal.woff2").write_bytes(b"data") + + sphinx_fonts._on_builder_inited(app) + + assert len(app._font_faces) == 2 + assert app._font_faces[0]["family"] == "Open Sans" + assert app._font_faces[0]["weight"] == "400" + assert app._font_faces[1]["weight"] == "700" + assert app._font_preload_hrefs == [] + assert app._font_fallbacks == [] + assert app._font_css_variables == {} + + +def test_on_builder_inited_download_failure( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """_on_builder_inited skips font_faces entry on download failure.""" + monkeypatch.setattr("sphinx_fonts._cache_dir", lambda: tmp_path / "cache") + + msg = "offline" + + def fake_urlretrieve(url: str, filename: t.Any) -> t.NoReturn: + raise urllib.error.URLError(msg) + + monkeypatch.setattr("sphinx_fonts.urllib.request.urlretrieve", fake_urlretrieve) + + fonts = [ + { + "package": "@fontsource/inter", + "version": "5.0.0", + "family": "Inter", + "weights": [400], + "styles": ["normal"], + }, + ] + app = _make_app(tmp_path, fonts=fonts) + + sphinx_fonts._on_builder_inited(app) + + assert len(app._font_faces) == 0 + + +def test_on_builder_inited_explicit_subset( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """_on_builder_inited respects explicit subset in font config.""" + monkeypatch.setattr("sphinx_fonts._cache_dir", lambda: tmp_path / "cache") + + fonts = [ + { + "package": "@fontsource/noto-sans", + "version": "5.0.0", + "family": "Noto Sans", + "subset": "latin-ext", + "weights": [400], + "styles": ["normal"], + }, + ] + app = _make_app(tmp_path, fonts=fonts) + + cache = tmp_path / "cache" + cache.mkdir(parents=True) + (cache / "noto-sans-latin-ext-400-normal.woff2").write_bytes(b"data") + + sphinx_fonts._on_builder_inited(app) + + assert app._font_faces[0]["filename"] == "noto-sans-latin-ext-400-normal.woff2" + + +def test_on_builder_inited_preload_match( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """_on_builder_inited builds preload_hrefs for matching preload specs.""" + monkeypatch.setattr("sphinx_fonts._cache_dir", lambda: tmp_path / "cache") + + fonts = [ + { + "package": "@fontsource/open-sans", + "version": "5.2.5", + "family": "Open Sans", + "weights": [400], + "styles": ["normal"], + }, + ] + preload = [("Open Sans", 400, "normal")] + app = _make_app(tmp_path, fonts=fonts, preload=preload) + + cache = tmp_path / "cache" + cache.mkdir(parents=True) + (cache / "open-sans-latin-400-normal.woff2").write_bytes(b"data") + + sphinx_fonts._on_builder_inited(app) + + assert app._font_preload_hrefs == ["open-sans-latin-400-normal.woff2"] + + +def test_on_builder_inited_preload_no_match( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """_on_builder_inited produces empty preload when family doesn't match.""" + monkeypatch.setattr("sphinx_fonts._cache_dir", lambda: tmp_path / "cache") + + fonts = [ + { + "package": "@fontsource/open-sans", + "version": "5.2.5", + "family": "Open Sans", + "weights": [400], + "styles": ["normal"], + }, + ] + preload = [("Nonexistent Font", 400, "normal")] + app = _make_app(tmp_path, fonts=fonts, preload=preload) + + cache = tmp_path / "cache" + cache.mkdir(parents=True) + (cache / "open-sans-latin-400-normal.woff2").write_bytes(b"data") + + sphinx_fonts._on_builder_inited(app) + + assert app._font_preload_hrefs == [] + + +def test_on_builder_inited_fallbacks_and_variables( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """_on_builder_inited stores fallbacks and CSS variables on app.""" + monkeypatch.setattr("sphinx_fonts._cache_dir", lambda: tmp_path / "cache") + + fonts = [ + { + "package": "@fontsource/inter", + "version": "5.0.0", + "family": "Inter", + "weights": [400], + "styles": ["normal"], + }, + ] + fallbacks = [{"family": "system-ui", "style": "normal", "weight": "400"}] + variables = {"--font-body": "Inter, system-ui"} + app = _make_app(tmp_path, fonts=fonts, fallbacks=fallbacks, variables=variables) + + cache = tmp_path / "cache" + cache.mkdir(parents=True) + (cache / "inter-latin-400-normal.woff2").write_bytes(b"data") + + sphinx_fonts._on_builder_inited(app) + + assert app._font_fallbacks == fallbacks + assert app._font_css_variables == variables + + +# --- _on_html_page_context tests --- + + +def test_on_html_page_context_with_attrs() -> None: + """_on_html_page_context injects font data from app attributes.""" + app = types.SimpleNamespace( + _font_preload_hrefs=["font-400.woff2"], + _font_faces=[ + { + "family": "Inter", + "weight": "400", + "style": "normal", + "filename": "font-400.woff2", + }, + ], + _font_fallbacks=[{"family": "system-ui"}], + _font_css_variables={"--font-body": "Inter"}, + ) + context: dict[str, t.Any] = {} + + sphinx_fonts._on_html_page_context( + app, + "index", + "page.html", + context, + None, + ) + + assert context["font_preload_hrefs"] == ["font-400.woff2"] + assert context["font_faces"] == app._font_faces + assert context["font_fallbacks"] == [{"family": "system-ui"}] + assert context["font_css_variables"] == {"--font-body": "Inter"} + + +def test_on_html_page_context_without_attrs() -> None: + """_on_html_page_context uses defaults when app attrs are missing.""" + app = types.SimpleNamespace() + context: dict[str, t.Any] = {} + + sphinx_fonts._on_html_page_context( + app, + "index", + "page.html", + context, + None, + ) + + assert context["font_preload_hrefs"] == [] + assert context["font_faces"] == [] + assert context["font_fallbacks"] == [] + assert context["font_css_variables"] == {} + + +# --- setup tests --- + + +def test_setup_return_value() -> None: + """Verify setup() returns correct metadata dict.""" + config_values: list[tuple[str, t.Any, str]] = [] + connections: list[tuple[str, t.Any]] = [] + + app = types.SimpleNamespace( + add_config_value=lambda name, default, rebuild: config_values.append( + (name, default, rebuild) + ), + connect=lambda event, handler: connections.append((event, handler)), + ) + + result = sphinx_fonts.setup(app) + + assert result == { + "version": "1.0", + "parallel_read_safe": True, + "parallel_write_safe": True, + } + + +def test_setup_config_values() -> None: + """Verify setup() registers all expected config values.""" + config_values: list[tuple[str, t.Any, str]] = [] + connections: list[tuple[str, t.Any]] = [] + + app = types.SimpleNamespace( + add_config_value=lambda name, default, rebuild: config_values.append( + (name, default, rebuild) + ), + connect=lambda event, handler: connections.append((event, handler)), + ) + + sphinx_fonts.setup(app) + + config_names = [c[0] for c in config_values] + assert "sphinx_fonts" in config_names + assert "sphinx_font_fallbacks" in config_names + assert "sphinx_font_css_variables" in config_names + assert "sphinx_font_preload" in config_names + assert all(c[2] == "html" for c in config_values) + + +def test_setup_event_connections() -> None: + """Verify setup() connects to builder-inited and html-page-context events.""" + config_values: list[tuple[str, t.Any, str]] = [] + connections: list[tuple[str, t.Any]] = [] + + app = types.SimpleNamespace( + add_config_value=lambda name, default, rebuild: config_values.append( + (name, default, rebuild) + ), + connect=lambda event, handler: connections.append((event, handler)), + ) + + sphinx_fonts.setup(app) + + event_names = [c[0] for c in connections] + assert "builder-inited" in event_names + assert "html-page-context" in event_names + + handlers = {c[0]: c[1] for c in connections} + assert handlers["builder-inited"] is sphinx_fonts._on_builder_inited + assert handlers["html-page-context"] is sphinx_fonts._on_html_page_context From b32f996eec8959ea5ecdc80da1116d879a6cab68 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 18:23:52 -0500 Subject: [PATCH 49/50] chore(mypy[overrides]): Add sphinx_fonts to ignore_missing_imports why: mypy reports `import-not-found` for `sphinx_fonts` since the package does not ship type stubs or a py.typed marker. what: - Add "sphinx_fonts" to the [[tool.mypy.overrides]] module list - Position it alongside other Sphinx extension ignores (sphinx_argparse_neo, sphinx_argparse_neo.*) --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index e394750acc..9e3f8c75fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -173,6 +173,7 @@ module = [ "bpython", "sphinx_argparse_neo", "sphinx_argparse_neo.*", + "sphinx_fonts", "cli_usage_lexer", "argparse_lexer", "argparse_roles", From 7ae91c0c2b5680a06d5af5213338c92be9988a60 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 15 Mar 2026 06:44:51 -0500 Subject: [PATCH 50/50] docs: Improve command formatting (#1024) why: Standardize shell code blocks to follow documentation guidelines what: - Use `console` language tag with `$ ` prefix for shell commands - Split long pipx install command with `\` line continuations - Split long env commands in docs/developing.md - Add Shell Command Formatting rules to AGENTS.md --- AGENTS.md | 38 +++++++++++++++++++++++++++++++++++++- CHANGES | 18 +++++++++++------- docs/cli/completion.md | 12 ++++++------ docs/developing.md | 6 ++++-- 4 files changed, 58 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bdbe24f926..7e230283db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -282,7 +282,7 @@ Raw `print()` is forbidden in command/business logic. The `print()` call lives o When writing documentation (README, CHANGES, docs/), follow these rules for code blocks: -**One command per code block.** This makes commands individually copyable. +**One command per code block.** This makes commands individually copyable. For sequential commands, either use separate code blocks or chain them with `&&` or `;` and `\` continuations (keeping it one logical command). **Put explanations outside the code block**, not as comments inside. @@ -310,6 +310,42 @@ $ uv run pytest $ uv run pytest --cov ``` +### Shell Command Formatting + +These rules apply to shell commands in documentation (README, CHANGES, docs/), **not** to Python doctests. + +**Use `console` language tag with `$ ` prefix.** This distinguishes interactive commands from scripts and enables prompt-aware copy in many terminals. + +Good: + +```console +$ uv run pytest +``` + +Bad: + +```bash +uv run pytest +``` + +**Split long commands with `\` for readability.** Each flag or flag+value pair gets its own continuation line, indented. Positional parameters go on the final line. + +Good: + +```console +$ pipx install \ + --suffix=@next \ + --pip-args '\--pre' \ + --force \ + 'tmuxp' +``` + +Bad: + +```console +$ pipx install --suffix=@next --pip-args '\--pre' --force 'tmuxp' +``` + ## Important Notes - **QA every edit**: Run formatting and tests before committing diff --git a/CHANGES b/CHANGES index 707359f3ac..0330651353 100644 --- a/CHANGES +++ b/CHANGES @@ -23,7 +23,11 @@ $ uvx --from 'tmuxp' --prerelease allow tmuxp [pipx](https://pypa.github.io/pipx/docs/): ```console -$ pipx install --suffix=@next 'tmuxp' --pip-args '\--pre' --force +$ pipx install \ + --suffix=@next \ + --pip-args '\--pre' \ + --force \ + 'tmuxp' // Usage: tmuxp@next load yoursession ``` @@ -453,8 +457,8 @@ _Maintenance only, no bug fixes or new features_ via ruff v0.8.4, all automated lint fixes, including unsafe and previews were applied for Python 3.9: - ```sh - ruff check --select ALL . --fix --unsafe-fixes --preview --show-fixes; ruff format . + ```console + $ ruff check --select ALL . --fix --unsafe-fixes --preview --show-fixes; ruff format . ``` ## tmuxp 1.49.0 (2024-11-26) @@ -560,14 +564,14 @@ _Maintenance only, no bug fixes or new features_ via ruff v0.3.4, all automated lint fixes, including unsafe and previews were applied: - ```sh - ruff check --select ALL . --fix --unsafe-fixes --preview --show-fixes; ruff format . + ```console + $ ruff check --select ALL . --fix --unsafe-fixes --preview --show-fixes; ruff format . ``` Branches were treated with: - ```sh - git rebase \ + ```console + $ git rebase \ --strategy-option=theirs \ --exec 'poetry run ruff check --select ALL . --fix --unsafe-fixes --preview --show-fixes; poetry run ruff format .; git add src tests; git commit --amend --no-edit' \ origin/master diff --git a/docs/cli/completion.md b/docs/cli/completion.md index ee7f1f3fa1..410aed4ee3 100644 --- a/docs/cli/completion.md +++ b/docs/cli/completion.md @@ -32,8 +32,8 @@ $ uvx shtab --help :::{tab} bash -```bash -shtab --shell=bash -u tmuxp.cli.create_parser \ +```console +$ shtab --shell=bash -u tmuxp.cli.create_parser \ | sudo tee "$BASH_COMPLETION_COMPAT_DIR"/TMUXP ``` @@ -41,8 +41,8 @@ shtab --shell=bash -u tmuxp.cli.create_parser \ :::{tab} zsh -```zsh -shtab --shell=zsh -u tmuxp.cli.create_parser \ +```console +$ shtab --shell=zsh -u tmuxp.cli.create_parser \ | sudo tee /usr/local/share/zsh/site-functions/_TMUXP ``` @@ -50,8 +50,8 @@ shtab --shell=zsh -u tmuxp.cli.create_parser \ :::{tab} tcsh -```zsh -shtab --shell=tcsh -u tmuxp.cli.create_parser \ +```console +$ shtab --shell=tcsh -u tmuxp.cli.create_parser \ | sudo tee /etc/profile.d/TMUXP.completion.csh ``` diff --git a/docs/developing.md b/docs/developing.md index 1b909363df..cceb9eb61e 100644 --- a/docs/developing.md +++ b/docs/developing.md @@ -175,13 +175,15 @@ $ env PYTEST_ADDOPTS="tests/workspace/test_builder.py" uv run make start Drop into `test_automatic_rename_option()` in `tests/workspace/test_builder.py`: ```console -$ env PYTEST_ADDOPTS="-s -x -vv tests/workspace/test_builder.py" uv run make start +$ env PYTEST_ADDOPTS="-s -x -vv tests/workspace/test_builder.py" \ + uv run make start ``` Drop into `test_automatic_rename_option()` in `tests/workspace/test_builder.py` and stop on first error: ```console -$ env PYTEST_ADDOPTS="-s -x -vv tests/workspace/test_builder.py::test_automatic_rename_option" uv run make start +$ env PYTEST_ADDOPTS="-s -x -vv tests/workspace/test_builder.py::test_automatic_rename_option" \ + uv run make start ``` Drop into `pdb` on first error: