From 75fed6583b1795168b9a54abfe0d7aa486b1f328 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 3 Jan 2026 11:13:09 -0600 Subject: [PATCH 01/99] feat(cli): Add semantic color system with --color flag Port vcspull's intuitive color practices into tmuxp CLI: - Add _colors.py module with ColorMode enum and Colors class - Add global --color flag (auto/always/never) to CLI - Support NO_COLOR and FORCE_COLOR environment variables - Update load.py with semantic colors (info, success, error, etc.) - Update ls.py and debug_info.py to use Colors class The Colors class wraps tmuxp's existing style() function with semantic methods (success, warning, error, info, highlight, muted) and handles TTY detection following CPython's _colorize patterns. --- src/tmuxp/cli/__init__.py | 26 ++- src/tmuxp/cli/_colors.py | 352 ++++++++++++++++++++++++++++++++++++ src/tmuxp/cli/debug_info.py | 22 ++- src/tmuxp/cli/load.py | 118 +++++++----- src/tmuxp/cli/ls.py | 20 +- 5 files changed, 479 insertions(+), 59 deletions(-) create mode 100644 src/tmuxp/cli/_colors.py diff --git a/src/tmuxp/cli/__init__.py b/src/tmuxp/cli/__init__.py index 2b542face5..5e38cb33ca 100644 --- a/src/tmuxp/cli/__init__.py +++ b/src/tmuxp/cli/__init__.py @@ -17,7 +17,11 @@ from tmuxp.log import setup_logger from .convert import command_convert, create_convert_subparser -from .debug_info import command_debug_info, create_debug_info_subparser +from .debug_info import ( + CLIDebugInfoNamespace, + command_debug_info, + create_debug_info_subparser, +) from .edit import command_edit, create_edit_subparser from .freeze import CLIFreezeNamespace, command_freeze, create_freeze_subparser from .import_config import ( @@ -26,7 +30,7 @@ create_import_subparser, ) from .load import CLILoadNamespace, command_load, create_load_subparser -from .ls import command_ls, create_ls_subparser +from .ls import CLILsNamespace, command_ls, create_ls_subparser from .shell import CLIShellNamespace, command_shell, create_shell_subparser from .utils import tmuxp_echo @@ -37,6 +41,7 @@ from typing import TypeAlias CLIVerbosity: TypeAlias = t.Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] + CLIColorMode: TypeAlias = t.Literal["auto", "always", "never"] CLISubparserName: TypeAlias = t.Literal[ "ls", "load", @@ -67,6 +72,12 @@ def create_parser() -> argparse.ArgumentParser: choices=["debug", "info", "warning", "error", "critical"], help='log level (debug, info, warning, error, critical) (default "info")', ) + parser.add_argument( + "--color", + choices=["auto", "always", "never"], + default="auto", + help="when to use colors: auto (default), always, or never", + ) subparsers = parser.add_subparsers(dest="subparser_name") load_parser = subparsers.add_parser("load", help="load tmuxp workspaces") create_load_subparser(load_parser) @@ -112,6 +123,7 @@ class CLINamespace(argparse.Namespace): """Typed :class:`argparse.Namespace` for tmuxp root-level CLI.""" log_level: CLIVerbosity + color: CLIColorMode subparser_name: CLISubparserName import_subparser_name: CLIImportSubparserName | None version: bool @@ -176,7 +188,10 @@ def cli(_args: list[str] | None = None) -> None: parser=parser, ) elif args.subparser_name == "debug-info": - command_debug_info(parser=parser) + command_debug_info( + args=CLIDebugInfoNamespace(**vars(args)), + parser=parser, + ) elif args.subparser_name == "edit": command_edit( @@ -189,7 +204,10 @@ def cli(_args: list[str] | None = None) -> None: parser=parser, ) elif args.subparser_name == "ls": - command_ls(parser=parser) + command_ls( + args=CLILsNamespace(**vars(args)), + parser=parser, + ) def startup(config_dir: pathlib.Path) -> None: diff --git a/src/tmuxp/cli/_colors.py b/src/tmuxp/cli/_colors.py new file mode 100644 index 0000000000..d5521f9f9a --- /dev/null +++ b/src/tmuxp/cli/_colors.py @@ -0,0 +1,352 @@ +"""Color output utilities for tmuxp CLI. + +This module provides semantic color utilities following patterns from vcspull +and CPython's _colorize module. It integrates with the existing style() function +in utils.py for ANSI rendering. + +Examples +-------- +Basic usage with automatic TTY detection: + +>>> colors = Colors() # AUTO mode by default +>>> # In a TTY, this returns colored text; otherwise plain text + +Force colors on or off: + +>>> colors = Colors(ColorMode.ALWAYS) +>>> colors.success("loaded") # doctest: +ELLIPSIS +'...' + +>>> colors = Colors(ColorMode.NEVER) +>>> colors.success("loaded") +'loaded' + +Environment variables (NO_COLOR, FORCE_COLOR) are respected: + +>>> import os +>>> # NO_COLOR takes highest priority +>>> # FORCE_COLOR enables colors even without TTY +""" + +from __future__ import annotations + +import enum +import os +import sys + +from .utils import style + + +class ColorMode(enum.Enum): + """Color output modes for CLI. + + Examples + -------- + >>> ColorMode.AUTO.value + 'auto' + >>> ColorMode.ALWAYS.value + 'always' + >>> ColorMode.NEVER.value + 'never' + """ + + AUTO = "auto" + ALWAYS = "always" + NEVER = "never" + + +class Colors: + r"""Semantic color utilities for CLI output. + + Provides semantic color methods (success, warning, error, etc.) that + conditionally apply ANSI colors based on the color mode and environment. + + Parameters + ---------- + mode : ColorMode + Color mode to use. Default is AUTO which detects TTY. + + Attributes + ---------- + SUCCESS : str + Color name for success messages (green) + WARNING : str + Color name for warning messages (yellow) + ERROR : str + Color name for error messages (red) + INFO : str + Color name for informational messages (cyan) + HIGHLIGHT : str + Color name for highlighted/important text (magenta) + MUTED : str + Color name for subdued/secondary text (blue) + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.success("session loaded") + 'session loaded' + >>> colors.error("failed to load") + 'failed to load' + + >>> colors = Colors(ColorMode.ALWAYS) + >>> result = colors.success("ok") + >>> "\033[" in result # Contains ANSI escape + True + """ + + # Semantic color names (used with style()) + SUCCESS = "green" # Success, loaded, up-to-date + WARNING = "yellow" # Warnings, changes needed + ERROR = "red" # Errors, failures + INFO = "cyan" # Information, paths + HIGHLIGHT = "magenta" # Important labels, session names + MUTED = "blue" # Subdued info, secondary text + + def __init__(self, mode: ColorMode = ColorMode.AUTO) -> None: + """Initialize color manager. + + Parameters + ---------- + mode : ColorMode + Color mode to use (auto, always, never). Default is AUTO. + + Examples + -------- + >>> colors = Colors() + >>> colors.mode + + + >>> colors = Colors(ColorMode.NEVER) + >>> colors._enabled + False + """ + self.mode = mode + self._enabled = self._should_enable() + + def _should_enable(self) -> bool: + """Determine if color should be enabled. + + Follows CPython-style precedence: + 1. NO_COLOR env var (any value) -> disable + 2. ColorMode.NEVER -> disable + 3. ColorMode.ALWAYS -> enable + 4. FORCE_COLOR env var (any value) -> enable + 5. TTY check -> enable if stdout is a terminal + + Returns + ------- + bool + True if colors should be enabled. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors._should_enable() + False + """ + # NO_COLOR takes highest priority (standard convention) + if os.environ.get("NO_COLOR"): + return False + + if self.mode == ColorMode.NEVER: + return False + if self.mode == ColorMode.ALWAYS: + return True + + # AUTO mode: check FORCE_COLOR then TTY + if os.environ.get("FORCE_COLOR"): + return True + + return sys.stdout.isatty() + + def _colorize(self, text: str, fg: str, bold: bool = False) -> str: + """Apply color using existing style() function. + + Parameters + ---------- + text : str + Text to colorize. + fg : str + Foreground color name (e.g., "green", "red"). + bold : bool + Whether to apply bold style. Default is False. + + Returns + ------- + str + Colorized text if enabled, plain text otherwise. + """ + if self._enabled: + return style(text, fg=fg, bold=bold) + return text + + def success(self, text: str, bold: bool = False) -> str: + """Format text as success (green). + + Parameters + ---------- + text : str + Text to format. + bold : bool + Whether to apply bold style. Default is False. + + Returns + ------- + str + Formatted text. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.success("loaded") + 'loaded' + """ + return self._colorize(text, self.SUCCESS, bold) + + def warning(self, text: str, bold: bool = False) -> str: + """Format text as warning (yellow). + + Parameters + ---------- + text : str + Text to format. + bold : bool + Whether to apply bold style. Default is False. + + Returns + ------- + str + Formatted text. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.warning("check config") + 'check config' + """ + return self._colorize(text, self.WARNING, bold) + + def error(self, text: str, bold: bool = False) -> str: + """Format text as error (red). + + Parameters + ---------- + text : str + Text to format. + bold : bool + Whether to apply bold style. Default is False. + + Returns + ------- + str + Formatted text. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.error("failed") + 'failed' + """ + return self._colorize(text, self.ERROR, bold) + + def info(self, text: str, bold: bool = False) -> str: + """Format text as info (cyan). + + Parameters + ---------- + text : str + Text to format. + bold : bool + Whether to apply bold style. Default is False. + + Returns + ------- + str + Formatted text. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.info("/path/to/config.yaml") + '/path/to/config.yaml' + """ + return self._colorize(text, self.INFO, bold) + + def highlight(self, text: str, bold: bool = True) -> str: + """Format text as highlighted (magenta, bold by default). + + Parameters + ---------- + text : str + Text to format. + bold : bool + Whether to apply bold style. Default is True. + + Returns + ------- + str + Formatted text. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.highlight("my-session") + 'my-session' + """ + return self._colorize(text, self.HIGHLIGHT, bold) + + def muted(self, text: str) -> str: + """Format text as muted (blue, never bold). + + Parameters + ---------- + text : str + Text to format. + + Returns + ------- + str + Formatted text. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.muted("(optional)") + '(optional)' + """ + return self._colorize(text, self.MUTED, bold=False) + + +def get_color_mode(color_arg: str | None = None) -> ColorMode: + """Convert CLI argument string to ColorMode enum. + + Parameters + ---------- + color_arg : str | None + Color mode argument from CLI (auto, always, never). + None defaults to AUTO. + + Returns + ------- + ColorMode + The determined color mode. Invalid values return AUTO. + + Examples + -------- + >>> get_color_mode(None) + + >>> get_color_mode("always") + + >>> get_color_mode("NEVER") + + >>> get_color_mode("invalid") + + """ + if color_arg is None: + return ColorMode.AUTO + + try: + return ColorMode(color_arg.lower()) + except ValueError: + return ColorMode.AUTO diff --git a/src/tmuxp/cli/debug_info.py b/src/tmuxp/cli/debug_info.py index fdd55c83fd..9245c7df47 100644 --- a/src/tmuxp/cli/debug_info.py +++ b/src/tmuxp/cli/debug_info.py @@ -2,6 +2,7 @@ from __future__ import annotations +import argparse import os import pathlib import platform @@ -9,20 +10,28 @@ import sys import typing as t -from colorama import Fore from libtmux.__about__ import __version__ as libtmux_version from libtmux.common import get_version, tmux_cmd from tmuxp.__about__ import __version__ +from ._colors import Colors, get_color_mode from .utils import tmuxp_echo if t.TYPE_CHECKING: - import argparse + from typing import TypeAlias + + CLIColorModeLiteral: TypeAlias = t.Literal["auto", "always", "never"] tmuxp_path = pathlib.Path(__file__).parent.parent +class CLIDebugInfoNamespace(argparse.Namespace): + """Typed :class:`argparse.Namespace` for tmuxp debug-info command.""" + + color: CLIColorModeLiteral + + def create_debug_info_subparser( parser: argparse.ArgumentParser, ) -> argparse.ArgumentParser: @@ -31,9 +40,13 @@ def create_debug_info_subparser( def command_debug_info( + args: CLIDebugInfoNamespace | None = None, parser: argparse.ArgumentParser | None = None, ) -> None: """Entrypoint for ``tmuxp debug-info`` to print debug info to submit with issues.""" + # Get color mode from args or default to AUTO + color_mode = get_color_mode(args.color if args else None) + colors = Colors(color_mode) def prepend_tab(strings: list[str]) -> list[str]: """Prepend tab to strings in list.""" @@ -45,12 +58,11 @@ def output_break() -> str: def format_tmux_resp(std_resp: tmux_cmd) -> str: """Format tmux command response for tmuxp stdout.""" + stderr_lines = "\n".join(prepend_tab(std_resp.stderr)) return "\n".join( [ "\n".join(prepend_tab(std_resp.stdout)), - Fore.RED, - "\n".join(prepend_tab(std_resp.stderr)), - Fore.RESET, + colors.error(stderr_lines) if stderr_lines.strip() else "", ], ) diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index e9339493f0..30db4ac749 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -19,7 +19,8 @@ from tmuxp.workspace.builder import WorkspaceBuilder from tmuxp.workspace.finders import find_workspace_file, get_workspace_dir -from .utils import prompt_choices, prompt_yes_no, style, tmuxp_echo +from ._colors import ColorMode, Colors, get_color_mode +from .utils import prompt_choices, prompt_yes_no, tmuxp_echo if t.TYPE_CHECKING: from typing import TypeAlias @@ -30,6 +31,7 @@ from tmuxp.types import StrPath CLIColorsLiteral: TypeAlias = t.Literal[56, 88] + CLIColorModeLiteral: TypeAlias = t.Literal["auto", "always", "never"] class OptionOverrides(TypedDict): """Optional argument overrides for tmuxp load.""" @@ -47,13 +49,34 @@ class CLILoadNamespace(argparse.Namespace): tmux_config_file: str | None new_session_name: str | None answer_yes: bool | None + detached: bool append: bool | None colors: CLIColorsLiteral | None + color: CLIColorModeLiteral log_file: str | None -def load_plugins(session_config: dict[str, t.Any]) -> list[t.Any]: - """Load and return plugins in workspace.""" +def load_plugins( + session_config: dict[str, t.Any], + colors: Colors | None = None, +) -> list[t.Any]: + """Load and return plugins in workspace. + + Parameters + ---------- + session_config : dict + Session configuration dictionary. + colors : Colors | None + Colors instance for output formatting. If None, uses AUTO mode. + + Returns + ------- + list + List of loaded plugin instances. + """ + if colors is None: + colors = Colors(ColorMode.AUTO) + plugins = [] if "plugins" in session_config: for plugin in session_config["plugins"]: @@ -63,9 +86,9 @@ def load_plugins(session_config: dict[str, t.Any]) -> list[t.Any]: plugin_name = plugin.split(".")[-1] except Exception as error: tmuxp_echo( - style("[Plugin Error] ", fg="red") - + f"Couldn't load {plugin}\n" - + style(f"{error}", fg="yellow"), + colors.error("[Plugin Error]") + + f" Couldn't load {plugin}\n" + + colors.warning(f"{error}"), ) sys.exit(1) @@ -74,25 +97,19 @@ def load_plugins(session_config: dict[str, t.Any]) -> list[t.Any]: plugins.append(plugin()) except exc.TmuxpPluginException as error: if not prompt_yes_no( - "{}Skip loading {}?".format( - style( - str(error), - fg="yellow", - ), - plugin_name, - ), + f"{colors.warning(str(error))}Skip loading {plugin_name}?", default=True, ): tmuxp_echo( - style("[Not Skipping] ", fg="yellow") - + "Plugin versions constraint not met. Exiting...", + colors.warning("[Not Skipping]") + + " Plugin versions constraint not met. Exiting...", ) sys.exit(1) except Exception as error: tmuxp_echo( - style("[Plugin Error] ", fg="red") - + f"Couldn't load {plugin}\n" - + style(f"{error}", fg="yellow"), + colors.error("[Plugin Error]") + + f" Couldn't load {plugin}\n" + + colors.warning(f"{error}"), ) sys.exit(1) @@ -197,13 +214,14 @@ def _setup_plugins(builder: WorkspaceBuilder) -> Session: def load_workspace( workspace_file: StrPath, socket_name: str | None = None, - socket_path: None = None, + socket_path: str | None = None, tmux_config_file: str | None = None, new_session_name: str | None = None, colors: int | None = None, detached: bool = False, answer_yes: bool = False, append: bool = False, + cli_colors: Colors | None = None, ) -> Session | None: """Entrypoint for ``tmuxp load``, load a tmuxp "workspace" session via config file. @@ -217,9 +235,8 @@ def load_workspace( ``tmux -S `` new_session_name: str, options ``tmux new -s `` - colors : str, optional - '-2' - Force tmux to support 256 colors + colors : int, optional + Force tmux to support 256 or 88 colors. detached : bool Force detached state. default False. answer_yes : bool @@ -228,6 +245,8 @@ def load_workspace( append : bool Assume current when given prompt to append windows in same session. Default False. + cli_colors : Colors, optional + Colors instance for CLI output formatting. If None, uses AUTO mode. Notes ----- @@ -276,13 +295,16 @@ def load_workspace( behalf. An exception raised during this process means it's not easy to predict how broken the session is. """ + # Initialize CLI colors if not provided + if cli_colors is None: + cli_colors = Colors(ColorMode.AUTO) + # get the canonical path, eliminating any symlinks if isinstance(workspace_file, (str, os.PathLike)): workspace_file = pathlib.Path(workspace_file) tmuxp_echo( - style("[Loading] ", fg="green") - + style(str(workspace_file), fg="blue", bold=True), + cli_colors.info("[Loading]") + " " + cli_colors.highlight(str(workspace_file)), ) # ConfigReader allows us to open a yaml or json file as a dict @@ -313,11 +335,14 @@ def load_workspace( try: # load WorkspaceBuilder object for tmuxp workspace / tmux server builder = WorkspaceBuilder( session_config=expanded_workspace, - plugins=load_plugins(expanded_workspace), + plugins=load_plugins(expanded_workspace, colors=cli_colors), server=t, ) except exc.EmptyWorkspaceException: - tmuxp_echo(f"{workspace_file} is empty or parsed no workspace data") + tmuxp_echo( + cli_colors.warning("[Warning]") + + f" {workspace_file} is empty or parsed no workspace data", + ) return None session_name = expanded_workspace["session_name"] @@ -327,9 +352,7 @@ def load_workspace( if not detached and ( answer_yes or prompt_yes_no( - "{} is already running. Attach?".format( - style(session_name, fg="green"), - ), + f"{cli_colors.highlight(session_name)} is already running. Attach?", default=True, ) ): @@ -375,10 +398,11 @@ def load_workspace( import traceback tmuxp_echo(traceback.format_exc()) - tmuxp_echo(str(e)) + tmuxp_echo(cli_colors.error("[Error]") + f" {e}") choice = prompt_choices( - "Error loading workspace. (k)ill, (a)ttach, (d)etach?", + cli_colors.error("Error loading workspace.") + + " (k)ill, (a)ttach, (d)etach?", choices=["k", "a", "d"], default="k", ) @@ -386,7 +410,7 @@ def load_workspace( if choice == "k": if builder.session is not None: builder.session.kill() - tmuxp_echo("Session killed.") + tmuxp_echo(cli_colors.muted("Session killed.")) elif choice == "a": _reattach(builder) else: @@ -517,6 +541,9 @@ def command_load( """ util.oh_my_zsh_auto_title() + # Create Colors instance based on CLI --color flag + cli_colors = Colors(get_color_mode(args.color)) + if args.log_file: logfile_handler = logging.FileHandler(args.log_file) logfile_handler.setFormatter(log.LogFormatter()) @@ -524,27 +551,16 @@ def command_load( logger.addHandler(logfile_handler) - tmux_options = { - "socket_name": args.socket_name, - "socket_path": args.socket_path, - "tmux_config_file": args.tmux_config_file, - "new_session_name": args.new_session_name, - "answer_yes": args.answer_yes, - "colors": args.colors, - "detached": args.detached, - "append": args.append, - } - if args.workspace_files is None or len(args.workspace_files) == 0: - tmuxp_echo("Enter at least one config") + tmuxp_echo(cli_colors.error("Enter at least one config")) if parser is not None: parser.print_help() sys.exit() return last_idx = len(args.workspace_files) - 1 - original_detached_option = tmux_options.pop("detached") - original_new_session_name = tmux_options.pop("new_session_name") + original_detached_option = args.detached + original_new_session_name = args.new_session_name for idx, workspace_file in enumerate(args.workspace_files): workspace_file = find_workspace_file( @@ -561,7 +577,13 @@ def command_load( load_workspace( workspace_file, - detached=detached, + socket_name=args.socket_name, + socket_path=args.socket_path, + tmux_config_file=args.tmux_config_file, new_session_name=new_session_name, - **tmux_options, + colors=args.colors, + detached=detached, + answer_yes=args.answer_yes or False, + append=args.append or False, + cli_colors=cli_colors, ) diff --git a/src/tmuxp/cli/ls.py b/src/tmuxp/cli/ls.py index e1a8c7a610..d8fab16fc1 100644 --- a/src/tmuxp/cli/ls.py +++ b/src/tmuxp/cli/ls.py @@ -2,14 +2,25 @@ from __future__ import annotations +import argparse import os import typing as t from tmuxp.workspace.constants import VALID_WORKSPACE_DIR_FILE_EXTENSIONS from tmuxp.workspace.finders import get_workspace_dir +from ._colors import Colors, get_color_mode + if t.TYPE_CHECKING: - import argparse + from typing import TypeAlias + + CLIColorModeLiteral: TypeAlias = t.Literal["auto", "always", "never"] + + +class CLILsNamespace(argparse.Namespace): + """Typed :class:`argparse.Namespace` for tmuxp ls command.""" + + color: CLIColorModeLiteral def create_ls_subparser( @@ -20,13 +31,18 @@ def create_ls_subparser( def command_ls( + args: CLILsNamespace | None = None, parser: argparse.ArgumentParser | None = None, ) -> None: """Entrypoint for ``tmuxp ls`` subcommand.""" + # Get color mode from args or default to AUTO + color_mode = get_color_mode(args.color if args else None) + colors = Colors(color_mode) + tmuxp_dir = get_workspace_dir() if os.path.exists(tmuxp_dir) and os.path.isdir(tmuxp_dir): for f in sorted(os.listdir(tmuxp_dir)): stem, ext = os.path.splitext(f) if os.path.isdir(f) or ext not in VALID_WORKSPACE_DIR_FILE_EXTENSIONS: continue - print(stem) # NOQA: T201 RUF100 + print(colors.info(stem)) # NOQA: T201 RUF100 From 63fda2b0f604299e779a2a5a69ba2d1b3fccb23b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 3 Jan 2026 11:13:17 -0600 Subject: [PATCH 02/99] tests(cli): Add comprehensive tests for color system Add 25 tests covering: - ColorMode detection (TTY, NO_COLOR, FORCE_COLOR) - Semantic color methods (success, error, warning, etc.) - get_color_mode() helper function - Colors class behavior when disabled --- tests/cli/test_colors.py | 208 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 tests/cli/test_colors.py diff --git a/tests/cli/test_colors.py b/tests/cli/test_colors.py new file mode 100644 index 0000000000..d327f0eba4 --- /dev/null +++ b/tests/cli/test_colors.py @@ -0,0 +1,208 @@ +"""Tests for CLI color utilities.""" + +from __future__ import annotations + +import sys + +import pytest + +from tmuxp.cli._colors import ColorMode, Colors, get_color_mode + + +class TestColorMode: + """Tests for ColorMode enum and color detection.""" + + def test_auto_tty_enabled(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Colors enabled when stdout is TTY in AUTO mode.""" + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) + monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.delenv("FORCE_COLOR", raising=False) + colors = Colors(ColorMode.AUTO) + assert colors._enabled is True + + def test_auto_no_tty_disabled(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Colors disabled when stdout is not TTY in AUTO mode.""" + monkeypatch.setattr(sys.stdout, "isatty", lambda: False) + monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.delenv("FORCE_COLOR", raising=False) + colors = Colors(ColorMode.AUTO) + assert colors._enabled is False + + def test_no_color_env_respected(self, monkeypatch: pytest.MonkeyPatch) -> None: + """NO_COLOR environment variable disables colors even in ALWAYS mode.""" + monkeypatch.setenv("NO_COLOR", "1") + colors = Colors(ColorMode.ALWAYS) + assert colors._enabled is False + + def test_no_color_any_value(self, monkeypatch: pytest.MonkeyPatch) -> None: + """NO_COLOR with any non-empty value disables colors.""" + monkeypatch.setenv("NO_COLOR", "yes") + colors = Colors(ColorMode.ALWAYS) + assert colors._enabled is False + + def test_force_color_env_respected(self, monkeypatch: pytest.MonkeyPatch) -> None: + """FORCE_COLOR environment variable enables colors in AUTO mode without TTY.""" + monkeypatch.setattr(sys.stdout, "isatty", lambda: False) + monkeypatch.setenv("FORCE_COLOR", "1") + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.AUTO) + assert colors._enabled is True + + def test_no_color_takes_precedence(self, monkeypatch: pytest.MonkeyPatch) -> None: + """NO_COLOR takes precedence over FORCE_COLOR.""" + monkeypatch.setenv("NO_COLOR", "1") + monkeypatch.setenv("FORCE_COLOR", "1") + colors = Colors(ColorMode.ALWAYS) + assert colors._enabled is False + + def test_never_mode_disables(self, monkeypatch: pytest.MonkeyPatch) -> None: + """ColorMode.NEVER always disables colors.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.NEVER) + assert colors._enabled is False + # Output should be plain text without ANSI codes + assert colors.success("test") == "test" + assert colors.error("fail") == "fail" + assert colors.warning("warn") == "warn" + assert colors.info("info") == "info" + assert colors.highlight("hl") == "hl" + assert colors.muted("mute") == "mute" + + def test_always_mode_enables(self, monkeypatch: pytest.MonkeyPatch) -> None: + """ColorMode.ALWAYS enables colors (unless NO_COLOR set).""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + assert colors._enabled is True + # Output should contain ANSI escape codes + assert "\033[" in colors.success("test") + + +class TestSemanticColors: + """Tests for semantic color methods.""" + + @pytest.fixture() + def colors(self, monkeypatch: pytest.MonkeyPatch) -> Colors: + """Create a Colors instance with colors enabled.""" + monkeypatch.delenv("NO_COLOR", raising=False) + return Colors(ColorMode.ALWAYS) + + def test_success_applies_green(self, colors: Colors) -> None: + """success() applies green color.""" + result = colors.success("ok") + assert "\033[32m" in result # green foreground + assert "ok" in result + assert result.endswith("\033[0m") # reset at end + + def test_error_applies_red(self, colors: Colors) -> None: + """error() applies red color.""" + result = colors.error("fail") + assert "\033[31m" in result # red foreground + assert "fail" in result + + def test_warning_applies_yellow(self, colors: Colors) -> None: + """warning() applies yellow color.""" + result = colors.warning("caution") + assert "\033[33m" in result # yellow foreground + assert "caution" in result + + def test_info_applies_cyan(self, colors: Colors) -> None: + """info() applies cyan color.""" + result = colors.info("message") + assert "\033[36m" in result # cyan foreground + assert "message" in result + + def test_highlight_applies_magenta_bold(self, colors: Colors) -> None: + """highlight() applies magenta color with bold by default.""" + result = colors.highlight("important") + assert "\033[35m" in result # magenta foreground + assert "\033[1m" in result # bold + assert "important" in result + + def test_highlight_no_bold(self, colors: Colors) -> None: + """highlight() can be used without bold.""" + result = colors.highlight("important", bold=False) + assert "\033[35m" in result # magenta foreground + assert "\033[1m" not in result # no bold + assert "important" in result + + def test_muted_applies_blue(self, colors: Colors) -> None: + """muted() applies blue color without bold.""" + result = colors.muted("secondary") + assert "\033[34m" in result # blue foreground + assert "\033[1m" not in result # never bold + assert "secondary" in result + + def test_success_with_bold(self, colors: Colors) -> None: + """success() can be used with bold.""" + result = colors.success("done", bold=True) + assert "\033[32m" in result # green + assert "\033[1m" in result # bold + assert "done" in result + + +class TestGetColorMode: + """Tests for get_color_mode function.""" + + def test_none_returns_auto(self) -> None: + """None argument returns AUTO mode.""" + assert get_color_mode(None) == ColorMode.AUTO + + def test_auto_string(self) -> None: + """'auto' string returns AUTO mode.""" + assert get_color_mode("auto") == ColorMode.AUTO + + def test_always_string(self) -> None: + """'always' string returns ALWAYS mode.""" + assert get_color_mode("always") == ColorMode.ALWAYS + + def test_never_string(self) -> None: + """'never' string returns NEVER mode.""" + assert get_color_mode("never") == ColorMode.NEVER + + def test_case_insensitive(self) -> None: + """Color mode strings are case insensitive.""" + assert get_color_mode("ALWAYS") == ColorMode.ALWAYS + assert get_color_mode("Never") == ColorMode.NEVER + assert get_color_mode("AUTO") == ColorMode.AUTO + + def test_invalid_returns_auto(self) -> None: + """Invalid color mode strings return AUTO as fallback.""" + assert get_color_mode("invalid") == ColorMode.AUTO + assert get_color_mode("yes") == ColorMode.AUTO + assert get_color_mode("") == ColorMode.AUTO + + +class TestColorsClassAttributes: + """Tests for Colors class color name attributes.""" + + def test_semantic_color_names(self) -> None: + """Verify semantic color name attributes exist.""" + assert Colors.SUCCESS == "green" + assert Colors.WARNING == "yellow" + assert Colors.ERROR == "red" + assert Colors.INFO == "cyan" + assert Colors.HIGHLIGHT == "magenta" + assert Colors.MUTED == "blue" + + +class TestColorsDisabled: + """Tests for Colors behavior when disabled.""" + + def test_disabled_returns_plain_text(self) -> None: + """When colors are disabled, methods return plain text.""" + colors = Colors(ColorMode.NEVER) + assert colors.success("text") == "text" + assert colors.error("text") == "text" + assert colors.warning("text") == "text" + assert colors.info("text") == "text" + assert colors.highlight("text") == "text" + assert colors.muted("text") == "text" + + def test_disabled_preserves_text(self) -> None: + """Disabled colors preserve special characters.""" + colors = Colors(ColorMode.NEVER) + special = "path/to/file.yaml" + assert colors.info(special) == special + + with_spaces = "some message" + assert colors.success(with_spaces) == with_spaces From bb681ce82e61725b7b93a34ff83a723139cee8a1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 3 Jan 2026 11:21:44 -0600 Subject: [PATCH 03/99] feat(cli/freeze): Add semantic colors to freeze command Apply semantic color system to freeze command output: - error() for exception messages (session not found) - muted() for banner text and separators - info() for URLs and file paths - warning() for "file exists" messages - success() + info() for "Saved to " message Add CLIFreezeNamespace.color field for color mode from global --color flag. Includes 9 unit tests for color output formatting. --- src/tmuxp/cli/freeze.py | 36 +++++++++--- tests/cli/test_freeze_colors.py | 98 +++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 9 deletions(-) create mode 100644 tests/cli/test_freeze_colors.py diff --git a/src/tmuxp/cli/freeze.py b/src/tmuxp/cli/freeze.py index ebffaab633..f7cb4828bc 100644 --- a/src/tmuxp/cli/freeze.py +++ b/src/tmuxp/cli/freeze.py @@ -17,17 +17,20 @@ from tmuxp.workspace import freezer from tmuxp.workspace.finders import get_workspace_dir +from ._colors import Colors, get_color_mode from .utils import prompt, prompt_choices, prompt_yes_no if t.TYPE_CHECKING: from typing import TypeAlias, TypeGuard + CLIColorModeLiteral: TypeAlias = t.Literal["auto", "always", "never"] CLIOutputFormatLiteral: TypeAlias = t.Literal["yaml", "json"] class CLIFreezeNamespace(argparse.Namespace): """Typed :class:`argparse.Namespace` for tmuxp freeze command.""" + color: CLIColorModeLiteral session_name: str socket_name: str | None socket_path: str | None @@ -106,6 +109,9 @@ def command_freeze( If SESSION_NAME is provided, snapshot that session. Otherwise, use the current session. """ + color_mode = get_color_mode(args.color) + colors = Colors(color_mode) + server = Server(socket_name=args.socket_name, socket_path=args.socket_path) try: @@ -117,7 +123,7 @@ def command_freeze( if not session: raise exc.SessionNotFound except TmuxpException as e: - print(e) # NOQA: T201 RUF100 + print(colors.error(str(e))) # NOQA: T201 RUF100 return frozen_workspace = freezer.freeze(session) @@ -126,9 +132,12 @@ def command_freeze( if not args.quiet: print( # NOQA: T201 RUF100 - "---------------------------------------------------------------" - "\n" - "Freeze does its best to snapshot live tmux sessions.\n", + colors.muted( + "---------------------------------------------------------------" + ) + + "\n" + + colors.muted("Freeze does its best to snapshot live tmux sessions.") + + "\n", ) if not ( args.answer_yes @@ -138,9 +147,12 @@ def command_freeze( ): if not args.quiet: print( # NOQA: T201 RUF100 - "tmuxp has examples in JSON and YAML format at " - "\n" - "View tmuxp docs at .", + colors.muted("tmuxp has examples in JSON and YAML format at ") + + colors.info("") + + "\n" + + colors.muted("View tmuxp docs at ") + + colors.info("") + + ".", ) sys.exit() @@ -160,7 +172,11 @@ def command_freeze( default=save_to, ) if not args.force and os.path.exists(dest_prompt): - print(f"{dest_prompt} exists. Pick a new filename.") # NOQA: T201 RUF100 + print( # NOQA: T201 RUF100 + colors.warning(f"{dest_prompt} exists.") + + " " + + colors.muted("Pick a new filename."), + ) continue dest = dest_prompt @@ -216,4 +232,6 @@ def extract_workspace_format( ) if not args.quiet: - print(f"Saved to {dest}.") # NOQA: T201 RUF100 + print( # NOQA: T201 RUF100 + colors.success("Saved to ") + colors.info(dest) + ".", + ) diff --git a/tests/cli/test_freeze_colors.py b/tests/cli/test_freeze_colors.py new file mode 100644 index 0000000000..ebefde84d0 --- /dev/null +++ b/tests/cli/test_freeze_colors.py @@ -0,0 +1,98 @@ +"""Tests for CLI colors in freeze command.""" + +from __future__ import annotations + +import pytest + +from tmuxp.cli._colors import ColorMode, Colors + + +class TestFreezeColorOutput: + """Tests for freeze command color output formatting.""" + + @pytest.fixture() + def colors(self, monkeypatch: pytest.MonkeyPatch) -> Colors: + """Create a Colors instance with colors enabled.""" + monkeypatch.delenv("NO_COLOR", raising=False) + return Colors(ColorMode.ALWAYS) + + @pytest.fixture() + def colors_disabled(self) -> Colors: + """Create a Colors instance with colors disabled.""" + return Colors(ColorMode.NEVER) + + def test_freeze_error_uses_red(self, colors: Colors) -> None: + """Verify error messages use error color (red).""" + msg = "Session not found" + result = colors.error(msg) + assert "\033[31m" in result # red foreground + assert msg in result + assert result.endswith("\033[0m") # reset at end + + def test_freeze_success_message(self, colors: Colors) -> None: + """Verify success messages use success color (green).""" + result = colors.success("Saved to ") + assert "\033[32m" in result # green foreground + assert "Saved to" in result + + def test_freeze_file_path_uses_info(self, colors: Colors) -> None: + """Verify file paths use info color (cyan).""" + path = "/path/to/config.yaml" + result = colors.info(path) + assert "\033[36m" in result # cyan foreground + assert path in result + + def test_freeze_warning_file_exists(self, colors: Colors) -> None: + """Verify file exists warning uses warning color (yellow).""" + msg = "/path/to/config.yaml exists." + result = colors.warning(msg) + assert "\033[33m" in result # yellow foreground + assert msg in result + + def test_freeze_muted_for_secondary_text(self, colors: Colors) -> None: + """Verify secondary text uses muted color (blue).""" + msg = "Freeze does its best to snapshot live tmux sessions." + result = colors.muted(msg) + assert "\033[34m" in result # blue foreground + assert msg in result + + def test_freeze_colors_disabled_plain_text(self, colors_disabled: Colors) -> None: + """Verify disabled colors return plain text.""" + assert colors_disabled.error("error") == "error" + assert colors_disabled.success("success") == "success" + assert colors_disabled.warning("warning") == "warning" + assert colors_disabled.info("info") == "info" + assert colors_disabled.muted("muted") == "muted" + + def test_freeze_combined_output_format(self, colors: Colors) -> None: + """Verify combined success + info format for 'Saved to ' message.""" + dest = "/home/user/.tmuxp/session.yaml" + output = colors.success("Saved to ") + colors.info(dest) + "." + # Should contain both green and cyan ANSI codes + assert "\033[32m" in output # green for "Saved to" + assert "\033[36m" in output # cyan for path + assert "Saved to" in output + assert dest in output + assert output.endswith(".") + + def test_freeze_warning_with_instructions(self, colors: Colors) -> None: + """Verify warning + muted format for file exists message.""" + path = "/path/to/config.yaml" + output = ( + colors.warning(f"{path} exists.") + + " " + + colors.muted("Pick a new filename.") + ) + # Should contain both yellow and blue ANSI codes + assert "\033[33m" in output # yellow for warning + assert "\033[34m" in output # blue for muted + assert path in output + assert "Pick a new filename." in output + + def test_freeze_url_highlighted_in_help(self, colors: Colors) -> None: + """Verify URLs use info color in help text.""" + url = "" + help_text = colors.muted("tmuxp has examples at ") + colors.info(url) + assert "\033[34m" in help_text # blue for muted text + assert "\033[36m" in help_text # cyan for URL + assert url in help_text From 9bf4ebd085d3ace5c9d17a44e7100bdd98be132b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 3 Jan 2026 11:24:16 -0600 Subject: [PATCH 04/99] feat(cli/import): Add semantic colors to import command Apply semantic color system to import command output: - error() for "Unknown config format" error - muted() for separator and banner text - info() for URLs and file paths - success() + info() for "Saved to " message Update command_import_teamocil() and command_import_tmuxinator() to accept color parameter from global --color flag. Includes 9 unit tests for color output formatting. --- src/tmuxp/cli/__init__.py | 2 + src/tmuxp/cli/import_config.py | 43 ++++++++++---- tests/cli/test_import_colors.py | 102 ++++++++++++++++++++++++++++++++ 3 files changed, 136 insertions(+), 11 deletions(-) create mode 100644 tests/cli/test_import_colors.py diff --git a/src/tmuxp/cli/__init__.py b/src/tmuxp/cli/__init__.py index 5e38cb33ca..6af06b7767 100644 --- a/src/tmuxp/cli/__init__.py +++ b/src/tmuxp/cli/__init__.py @@ -175,11 +175,13 @@ def cli(_args: list[str] | None = None) -> None: command_import_teamocil( workspace_file=args.workspace_file, parser=parser, + color=args.color, ) elif import_subparser_name == "tmuxinator": command_import_tmuxinator( workspace_file=args.workspace_file, parser=parser, + color=args.color, ) elif args.subparser_name == "convert": command_convert( diff --git a/src/tmuxp/cli/import_config.py b/src/tmuxp/cli/import_config.py index fc34b8965d..c530245fb9 100644 --- a/src/tmuxp/cli/import_config.py +++ b/src/tmuxp/cli/import_config.py @@ -1,4 +1,4 @@ -"""CLI for ``tmuxp shell`` subcommand.""" +"""CLI for ``tmuxp import`` subcommand.""" from __future__ import annotations @@ -12,10 +12,14 @@ from tmuxp.workspace import importers from tmuxp.workspace.finders import find_workspace_file +from ._colors import ColorMode, Colors, get_color_mode from .utils import prompt, prompt_choices, prompt_yes_no, tmuxp_echo if t.TYPE_CHECKING: import argparse + from typing import TypeAlias + + CLIColorModeLiteral: TypeAlias = t.Literal["auto", "always", "never"] def get_tmuxinator_dir() -> pathlib.Path: @@ -141,8 +145,12 @@ def import_config( workspace_file: str, importfunc: ImportConfigFn, parser: argparse.ArgumentParser | None = None, + colors: Colors | None = None, ) -> None: """Import a configuration from a workspace_file.""" + if colors is None: + colors = Colors(ColorMode.AUTO) + existing_workspace_file = ConfigReader._from_file(pathlib.Path(workspace_file)) cfg_reader = ConfigReader(importfunc(existing_workspace_file)) @@ -157,12 +165,15 @@ def import_config( elif workspace_file_format == "json": new_config = cfg_reader.dump("json", indent=2) else: - sys.exit("Unknown config format.") + sys.exit(colors.error("Unknown config format.")) + separator = "---------------------------------------------------------------" tmuxp_echo( - new_config + "---------------------------------------------------------------" - "\n" - "Configuration import does its best to convert files.\n", + new_config + + colors.muted(separator) + + "\n" + + colors.muted("Configuration import does its best to convert files.") + + "\n", ) if prompt_yes_no( "The new config *WILL* require adjusting afterwards. Save config?", @@ -183,12 +194,14 @@ def import_config( encoding=locale.getpreferredencoding(False), ) - tmuxp_echo(f"Saved to {dest}.") + tmuxp_echo(colors.success("Saved to ") + colors.info(dest) + ".") else: tmuxp_echo( - "tmuxp has examples in JSON and YAML format at " - "\n" - "View tmuxp docs at ", + colors.muted("tmuxp has examples in JSON and YAML format at ") + + colors.info("") + + "\n" + + colors.muted("View tmuxp docs at ") + + colors.info(""), ) sys.exit() @@ -196,31 +209,39 @@ def import_config( def command_import_tmuxinator( workspace_file: str, parser: argparse.ArgumentParser | None = None, + color: CLIColorModeLiteral | None = None, ) -> None: """Entrypoint for ``tmuxp import tmuxinator`` subcommand. Converts a tmuxinator config from workspace_file to tmuxp format and import it into tmuxp. """ + color_mode = get_color_mode(color) + colors = Colors(color_mode) + workspace_file = find_workspace_file( workspace_file, workspace_dir=get_tmuxinator_dir(), ) - import_config(workspace_file, importers.import_tmuxinator) + import_config(workspace_file, importers.import_tmuxinator, colors=colors) def command_import_teamocil( workspace_file: str, parser: argparse.ArgumentParser | None = None, + color: CLIColorModeLiteral | None = None, ) -> None: """Entrypoint for ``tmuxp import teamocil`` subcommand. Convert a teamocil config from workspace_file to tmuxp format and import it into tmuxp. """ + color_mode = get_color_mode(color) + colors = Colors(color_mode) + workspace_file = find_workspace_file( workspace_file, workspace_dir=get_teamocil_dir(), ) - import_config(workspace_file, importers.import_teamocil) + import_config(workspace_file, importers.import_teamocil, colors=colors) diff --git a/tests/cli/test_import_colors.py b/tests/cli/test_import_colors.py new file mode 100644 index 0000000000..7e399e7bd1 --- /dev/null +++ b/tests/cli/test_import_colors.py @@ -0,0 +1,102 @@ +"""Tests for CLI colors in import command.""" + +from __future__ import annotations + +import pytest + +from tmuxp.cli._colors import ColorMode, Colors + + +class TestImportColorOutput: + """Tests for import command color output formatting.""" + + @pytest.fixture() + def colors(self, monkeypatch: pytest.MonkeyPatch) -> Colors: + """Create a Colors instance with colors enabled.""" + monkeypatch.delenv("NO_COLOR", raising=False) + return Colors(ColorMode.ALWAYS) + + @pytest.fixture() + def colors_disabled(self) -> Colors: + """Create a Colors instance with colors disabled.""" + return Colors(ColorMode.NEVER) + + def test_import_error_unknown_format(self, colors: Colors) -> None: + """Verify unknown format error uses error color (red).""" + msg = "Unknown config format." + result = colors.error(msg) + assert "\033[31m" in result # red foreground + assert msg in result + assert result.endswith("\033[0m") # reset at end + + def test_import_success_message(self, colors: Colors) -> None: + """Verify success messages use success color (green).""" + result = colors.success("Saved to ") + assert "\033[32m" in result # green foreground + assert "Saved to" in result + + def test_import_file_path_uses_info(self, colors: Colors) -> None: + """Verify file paths use info color (cyan).""" + path = "/path/to/config.yaml" + result = colors.info(path) + assert "\033[36m" in result # cyan foreground + assert path in result + + def test_import_muted_for_banner(self, colors: Colors) -> None: + """Verify banner text uses muted color (blue).""" + msg = "Configuration import does its best to convert files." + result = colors.muted(msg) + assert "\033[34m" in result # blue foreground + assert msg in result + + def test_import_muted_for_separator(self, colors: Colors) -> None: + """Verify separator uses muted color (blue).""" + separator = "---------------------------------------------------------------" + result = colors.muted(separator) + assert "\033[34m" in result # blue foreground + assert separator in result + + def test_import_colors_disabled_plain_text(self, colors_disabled: Colors) -> None: + """Verify disabled colors return plain text.""" + assert colors_disabled.error("error") == "error" + assert colors_disabled.success("success") == "success" + assert colors_disabled.muted("muted") == "muted" + assert colors_disabled.info("info") == "info" + + def test_import_combined_success_format(self, colors: Colors) -> None: + """Verify combined success + info format for 'Saved to ' message.""" + dest = "/home/user/.tmuxp/session.yaml" + output = colors.success("Saved to ") + colors.info(dest) + "." + # Should contain both green and cyan ANSI codes + assert "\033[32m" in output # green for "Saved to" + assert "\033[36m" in output # cyan for path + assert "Saved to" in output + assert dest in output + assert output.endswith(".") + + def test_import_help_text_with_urls(self, colors: Colors) -> None: + """Verify help text uses muted for text and info for URLs.""" + url = "" + help_text = colors.muted( + "tmuxp has examples in JSON and YAML format at " + ) + colors.info(url) + assert "\033[34m" in help_text # blue for muted text + assert "\033[36m" in help_text # cyan for URL + assert url in help_text + + def test_import_banner_with_separator(self, colors: Colors) -> None: + """Verify banner format with separator and instruction text.""" + config_content = "session_name: test\n" + separator = "---------------------------------------------------------------" + output = ( + config_content + + colors.muted(separator) + + "\n" + + colors.muted("Configuration import does its best to convert files.") + + "\n" + ) + # Should contain blue ANSI code for muted sections + assert "\033[34m" in output + assert separator in output + assert "Configuration import" in output + assert config_content in output From b8b14b238e5c44d13f1218be272f902d93f522be Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 3 Jan 2026 11:25:45 -0600 Subject: [PATCH 05/99] feat(cli/convert): Add semantic colors to convert command Apply semantic color system to convert command output: - highlight() for format type (json/yaml) in prompts - info() for file paths in prompts - success() + info() for "saved to " message Add color parameter to command_convert() from global --color flag. Includes 7 unit tests for color output formatting. --- src/tmuxp/cli/__init__.py | 1 + src/tmuxp/cli/convert.py | 20 ++++++-- tests/cli/test_convert_colors.py | 84 ++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 tests/cli/test_convert_colors.py diff --git a/src/tmuxp/cli/__init__.py b/src/tmuxp/cli/__init__.py index 6af06b7767..75651529db 100644 --- a/src/tmuxp/cli/__init__.py +++ b/src/tmuxp/cli/__init__.py @@ -188,6 +188,7 @@ def cli(_args: list[str] | None = None) -> None: workspace_file=args.workspace_file, answer_yes=args.answer_yes, parser=parser, + color=args.color, ) elif args.subparser_name == "debug-info": command_debug_info( diff --git a/src/tmuxp/cli/convert.py b/src/tmuxp/cli/convert.py index f537f5b3d4..eca56f659e 100644 --- a/src/tmuxp/cli/convert.py +++ b/src/tmuxp/cli/convert.py @@ -11,12 +11,15 @@ from tmuxp._internal.config_reader import ConfigReader from tmuxp.workspace.finders import find_workspace_file, get_workspace_dir +from ._colors import Colors, get_color_mode from .utils import prompt_yes_no if t.TYPE_CHECKING: import argparse + from typing import TypeAlias AllowedFileTypes = t.Literal["json", "yaml"] + CLIColorModeLiteral: TypeAlias = t.Literal["auto", "always", "never"] def create_convert_subparser( @@ -59,8 +62,12 @@ def command_convert( workspace_file: str | pathlib.Path, answer_yes: bool, parser: argparse.ArgumentParser | None = None, + color: CLIColorModeLiteral | None = None, ) -> None: """Entrypoint for ``tmuxp convert`` convert a tmuxp config between JSON and YAML.""" + color_mode = get_color_mode(color) + colors = Colors(color_mode) + workspace_file = find_workspace_file( workspace_file, workspace_dir=get_workspace_dir(), @@ -90,8 +97,11 @@ def command_convert( if ( not answer_yes - and prompt_yes_no(f"Convert to <{workspace_file}> to {to_filetype}?") - and prompt_yes_no(f"Save workspace to {newfile}?") + and prompt_yes_no( + f"Convert {colors.info(str(workspace_file))} to " + f"{colors.highlight(to_filetype)}?", + ) + and prompt_yes_no(f"Save workspace to {colors.info(str(newfile))}?") ): answer_yes = True @@ -100,4 +110,8 @@ def command_convert( new_workspace, encoding=locale.getpreferredencoding(False), ) - print(f"New workspace file saved to <{newfile}>.") # NOQA: T201 RUF100 + print( # NOQA: T201 RUF100 + colors.success("New workspace file saved to ") + + colors.info(f"<{newfile}>") + + ".", + ) diff --git a/tests/cli/test_convert_colors.py b/tests/cli/test_convert_colors.py new file mode 100644 index 0000000000..2ea8eb5202 --- /dev/null +++ b/tests/cli/test_convert_colors.py @@ -0,0 +1,84 @@ +"""Tests for CLI colors in convert command.""" + +from __future__ import annotations + +import pytest + +from tmuxp.cli._colors import ColorMode, Colors + + +class TestConvertColorOutput: + """Tests for convert command color output formatting.""" + + @pytest.fixture() + def colors(self, monkeypatch: pytest.MonkeyPatch) -> Colors: + """Create a Colors instance with colors enabled.""" + monkeypatch.delenv("NO_COLOR", raising=False) + return Colors(ColorMode.ALWAYS) + + @pytest.fixture() + def colors_disabled(self) -> Colors: + """Create a Colors instance with colors disabled.""" + return Colors(ColorMode.NEVER) + + def test_convert_success_message(self, colors: Colors) -> None: + """Verify success messages use success color (green).""" + result = colors.success("New workspace file saved to ") + assert "\033[32m" in result # green foreground + assert "New workspace file saved to" in result + + def test_convert_file_path_uses_info(self, colors: Colors) -> None: + """Verify file paths use info color (cyan).""" + path = "/path/to/config.yaml" + result = colors.info(path) + assert "\033[36m" in result # cyan foreground + assert path in result + + def test_convert_format_type_highlighted(self, colors: Colors) -> None: + """Verify format type uses highlight color (magenta + bold).""" + for fmt in ["json", "yaml"]: + result = colors.highlight(fmt) + assert "\033[35m" in result # magenta foreground + assert "\033[1m" in result # bold + assert fmt in result + + def test_convert_colors_disabled_plain_text(self, colors_disabled: Colors) -> None: + """Verify disabled colors return plain text.""" + assert colors_disabled.success("success") == "success" + assert colors_disabled.info("info") == "info" + assert colors_disabled.highlight("highlight") == "highlight" + + def test_convert_combined_success_format(self, colors: Colors) -> None: + """Verify combined success + info format for save message.""" + newfile = "/home/user/.tmuxp/session.json" + output = ( + colors.success("New workspace file saved to ") + + colors.info(f"<{newfile}>") + + "." + ) + # Should contain both green and cyan ANSI codes + assert "\033[32m" in output # green for success text + assert "\033[36m" in output # cyan for path + assert "New workspace file saved to" in output + assert newfile in output + assert output.endswith(".") + + def test_convert_prompt_format_with_highlight(self, colors: Colors) -> None: + """Verify prompt uses info for path and highlight for format.""" + workspace_file = "/path/to/config.yaml" + to_filetype = "json" + prompt = ( + f"Convert {colors.info(workspace_file)} to {colors.highlight(to_filetype)}?" + ) + assert "\033[36m" in prompt # cyan for file path + assert "\033[35m" in prompt # magenta for format type + assert workspace_file in prompt + assert to_filetype in prompt + + def test_convert_save_prompt_format(self, colors: Colors) -> None: + """Verify save prompt uses info color for new file path.""" + newfile = "/path/to/config.json" + prompt = f"Save workspace to {colors.info(newfile)}?" + assert "\033[36m" in prompt # cyan for file path + assert newfile in prompt + assert "Save workspace to" in prompt From e380f12fbe88b9940cb06df8479475d8e9df3933 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 3 Jan 2026 11:27:45 -0600 Subject: [PATCH 06/99] feat(cli/shell): Add semantic colors to shell command Apply semantic color system to shell command output: - muted() for static text ("Launching", "shell for session", "...") - highlight(bold=False) for shell type (ipython, pdb, etc.) - info() for session name Add CLIShellNamespace.color field for color mode from global --color flag. Note: 'colors' field (56/88 for tmux palette) is separate from 'color' (CLI output). Includes 7 unit tests for color output formatting. --- src/tmuxp/cli/shell.py | 21 +++++++ tests/cli/test_shell_colors.py | 102 +++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 tests/cli/test_shell_colors.py diff --git a/src/tmuxp/cli/shell.py b/src/tmuxp/cli/shell.py index 459e5350dc..2561865ba2 100644 --- a/src/tmuxp/cli/shell.py +++ b/src/tmuxp/cli/shell.py @@ -12,9 +12,12 @@ from tmuxp import util from tmuxp._compat import PY3, PYMINOR +from ._colors import Colors, get_color_mode + if t.TYPE_CHECKING: from typing import TypeAlias + CLIColorModeLiteral: TypeAlias = t.Literal["auto", "always", "never"] CLIColorsLiteral: TypeAlias = t.Literal[56, 88] CLIShellLiteral: TypeAlias = t.Literal[ "best", @@ -30,6 +33,7 @@ class CLIShellNamespace(argparse.Namespace): """Typed :class:`argparse.Namespace` for tmuxp shell command.""" + color: CLIColorModeLiteral session_name: str socket_name: str | None socket_path: str | None @@ -160,6 +164,9 @@ def command_shell( - :attr:`libtmux.Server.attached_sessions`, :attr:`libtmux.Session.active_window`, :attr:`libtmux.Window.active_pane` """ + color_mode = get_color_mode(args.color) + cli_colors = Colors(color_mode) + # If inside a server, detect socket_path env_tmux = os.getenv("TMUX") if env_tmux is not None and isinstance(env_tmux, str): @@ -198,11 +205,25 @@ def command_shell( ): from tmuxp._compat import breakpoint as tmuxp_breakpoint + print( # NOQA: T201 RUF100 + cli_colors.muted("Launching ") + + cli_colors.highlight("pdb", bold=False) + + cli_colors.muted(" shell..."), + ) tmuxp_breakpoint() return else: from tmuxp.shell import launch + shell_name = args.shell or "best" + print( # NOQA: T201 RUF100 + cli_colors.muted("Launching ") + + cli_colors.highlight(shell_name, bold=False) + + cli_colors.muted(" shell for session ") + + cli_colors.info(session.name or "") + + cli_colors.muted("..."), + ) + launch( shell=args.shell, use_pythonrc=args.use_pythonrc, # shell: code diff --git a/tests/cli/test_shell_colors.py b/tests/cli/test_shell_colors.py new file mode 100644 index 0000000000..a9671e4d22 --- /dev/null +++ b/tests/cli/test_shell_colors.py @@ -0,0 +1,102 @@ +"""Tests for CLI colors in shell command.""" + +from __future__ import annotations + +import pytest + +from tmuxp.cli._colors import ColorMode, Colors + + +class TestShellColorOutput: + """Tests for shell command color output formatting.""" + + @pytest.fixture() + def colors(self, monkeypatch: pytest.MonkeyPatch) -> Colors: + """Create a Colors instance with colors enabled.""" + monkeypatch.delenv("NO_COLOR", raising=False) + return Colors(ColorMode.ALWAYS) + + @pytest.fixture() + def colors_disabled(self) -> Colors: + """Create a Colors instance with colors disabled.""" + return Colors(ColorMode.NEVER) + + def test_shell_launch_message_format(self, colors: Colors) -> None: + """Verify launch message format with shell type and session.""" + shell_name = "ipython" + session_name = "my-session" + output = ( + colors.muted("Launching ") + + colors.highlight(shell_name, bold=False) + + colors.muted(" shell for session ") + + colors.info(session_name) + + colors.muted("...") + ) + # Should contain blue, magenta, and cyan ANSI codes + assert "\033[34m" in output # blue for muted + assert "\033[35m" in output # magenta for highlight + assert "\033[36m" in output # cyan for session name + assert shell_name in output + assert session_name in output + + def test_shell_pdb_launch_message(self, colors: Colors) -> None: + """Verify pdb launch message format.""" + output = ( + colors.muted("Launching ") + + colors.highlight("pdb", bold=False) + + colors.muted(" shell...") + ) + assert "\033[34m" in output # blue for muted + assert "\033[35m" in output # magenta for pdb + assert "pdb" in output + + def test_shell_highlight_not_bold(self, colors: Colors) -> None: + """Verify shell name uses highlight without bold for subtlety.""" + result = colors.highlight("best", bold=False) + assert "\033[35m" in result # magenta foreground + assert "\033[1m" not in result # no bold - subtle emphasis + assert "best" in result + + def test_shell_session_name_uses_info(self, colors: Colors) -> None: + """Verify session name uses info color (cyan).""" + session_name = "dev-session" + result = colors.info(session_name) + assert "\033[36m" in result # cyan foreground + assert session_name in result + + def test_shell_muted_for_static_text(self, colors: Colors) -> None: + """Verify static text uses muted color (blue).""" + result = colors.muted("Launching ") + assert "\033[34m" in result # blue foreground + assert "Launching" in result + + def test_shell_colors_disabled_plain_text(self, colors_disabled: Colors) -> None: + """Verify disabled colors return plain text.""" + shell_name = "ipython" + session_name = "my-session" + output = ( + colors_disabled.muted("Launching ") + + colors_disabled.highlight(shell_name, bold=False) + + colors_disabled.muted(" shell for session ") + + colors_disabled.info(session_name) + + colors_disabled.muted("...") + ) + # Should be plain text without ANSI codes + assert "\033[" not in output + assert output == f"Launching {shell_name} shell for session {session_name}..." + + def test_shell_various_shell_names(self, colors: Colors) -> None: + """Verify all shell types can be highlighted.""" + shell_types = [ + "best", + "pdb", + "code", + "ptipython", + "ptpython", + "ipython", + "bpython", + ] + for shell_name in shell_types: + result = colors.highlight(shell_name, bold=False) + assert "\033[35m" in result + assert shell_name in result From 2e3b3ebe6a41a81362b9f5a1692abc7a765851cf Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 3 Jan 2026 11:29:10 -0600 Subject: [PATCH 07/99] feat(cli/edit): Add semantic colors to edit command Apply semantic color system to edit command output: - muted() for static text ("Opening", "in", "...") - info() for workspace file path - highlight(bold=False) for editor name Add color parameter to command_edit() from global --color flag. Includes 6 unit tests for color output formatting. --- src/tmuxp/cli/__init__.py | 1 + src/tmuxp/cli/edit.py | 16 +++++++ tests/cli/test_edit_colors.py | 84 +++++++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+) create mode 100644 tests/cli/test_edit_colors.py diff --git a/src/tmuxp/cli/__init__.py b/src/tmuxp/cli/__init__.py index 75651529db..aa118de160 100644 --- a/src/tmuxp/cli/__init__.py +++ b/src/tmuxp/cli/__init__.py @@ -200,6 +200,7 @@ def cli(_args: list[str] | None = None) -> None: command_edit( workspace_file=args.workspace_file, parser=parser, + color=args.color, ) elif args.subparser_name == "freeze": command_freeze( diff --git a/src/tmuxp/cli/edit.py b/src/tmuxp/cli/edit.py index 075ca201dd..c73f448989 100644 --- a/src/tmuxp/cli/edit.py +++ b/src/tmuxp/cli/edit.py @@ -8,9 +8,14 @@ from tmuxp.workspace.finders import find_workspace_file +from ._colors import Colors, get_color_mode + if t.TYPE_CHECKING: import argparse import pathlib + from typing import TypeAlias + + CLIColorModeLiteral: TypeAlias = t.Literal["auto", "always", "never"] def create_edit_subparser( @@ -29,9 +34,20 @@ def create_edit_subparser( def command_edit( workspace_file: str | pathlib.Path, parser: argparse.ArgumentParser | None = None, + color: CLIColorModeLiteral | None = None, ) -> None: """Entrypoint for ``tmuxp edit``, open tmuxp workspace file in system editor.""" + color_mode = get_color_mode(color) + colors = Colors(color_mode) + workspace_file = find_workspace_file(workspace_file) sys_editor = os.environ.get("EDITOR", "vim") + print( # NOQA: T201 RUF100 + colors.muted("Opening ") + + colors.info(str(workspace_file)) + + colors.muted(" in ") + + colors.highlight(sys_editor, bold=False) + + colors.muted("..."), + ) subprocess.call([sys_editor, workspace_file]) diff --git a/tests/cli/test_edit_colors.py b/tests/cli/test_edit_colors.py new file mode 100644 index 0000000000..80203083a8 --- /dev/null +++ b/tests/cli/test_edit_colors.py @@ -0,0 +1,84 @@ +"""Tests for CLI colors in edit command.""" + +from __future__ import annotations + +import pytest + +from tmuxp.cli._colors import ColorMode, Colors + + +class TestEditColorOutput: + """Tests for edit command color output formatting.""" + + @pytest.fixture() + def colors(self, monkeypatch: pytest.MonkeyPatch) -> Colors: + """Create a Colors instance with colors enabled.""" + monkeypatch.delenv("NO_COLOR", raising=False) + return Colors(ColorMode.ALWAYS) + + @pytest.fixture() + def colors_disabled(self) -> Colors: + """Create a Colors instance with colors disabled.""" + return Colors(ColorMode.NEVER) + + def test_edit_opening_message_format(self, colors: Colors) -> None: + """Verify opening message format with file path and editor.""" + workspace_file = "/home/user/.tmuxp/dev.yaml" + editor = "vim" + output = ( + colors.muted("Opening ") + + colors.info(workspace_file) + + colors.muted(" in ") + + colors.highlight(editor, bold=False) + + colors.muted("...") + ) + # Should contain blue, cyan, and magenta ANSI codes + assert "\033[34m" in output # blue for muted + assert "\033[36m" in output # cyan for file path + assert "\033[35m" in output # magenta for editor + assert workspace_file in output + assert editor in output + + def test_edit_file_path_uses_info(self, colors: Colors) -> None: + """Verify file paths use info color (cyan).""" + path = "/path/to/workspace.yaml" + result = colors.info(path) + assert "\033[36m" in result # cyan foreground + assert path in result + + def test_edit_editor_highlighted(self, colors: Colors) -> None: + """Verify editor name uses highlight color without bold.""" + for editor in ["vim", "nano", "code", "emacs", "nvim"]: + result = colors.highlight(editor, bold=False) + assert "\033[35m" in result # magenta foreground + assert "\033[1m" not in result # no bold - subtle + assert editor in result + + def test_edit_muted_for_static_text(self, colors: Colors) -> None: + """Verify static text uses muted color (blue).""" + result = colors.muted("Opening ") + assert "\033[34m" in result # blue foreground + assert "Opening" in result + + def test_edit_colors_disabled_plain_text(self, colors_disabled: Colors) -> None: + """Verify disabled colors return plain text.""" + workspace_file = "/home/user/.tmuxp/dev.yaml" + editor = "vim" + output = ( + colors_disabled.muted("Opening ") + + colors_disabled.info(workspace_file) + + colors_disabled.muted(" in ") + + colors_disabled.highlight(editor, bold=False) + + colors_disabled.muted("...") + ) + # Should be plain text without ANSI codes + assert "\033[" not in output + assert output == f"Opening {workspace_file} in {editor}..." + + def test_edit_various_editors(self, colors: Colors) -> None: + """Verify common editors can be highlighted.""" + editors = ["vim", "nvim", "nano", "code", "emacs", "hx", "micro"] + for editor in editors: + result = colors.highlight(editor, bold=False) + assert "\033[35m" in result + assert editor in result From 5150dce320af372dc20dd4adff7c4f1e2568e2b3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 3 Jan 2026 11:30:12 -0600 Subject: [PATCH 08/99] tests(cli): Add integration tests for color output Add comprehensive integration tests verifying --color flag behavior: - --color=auto respects TTY detection - --color=always forces colors even without TTY - --color=never disables colors even with TTY - NO_COLOR environment variable overrides all modes - FORCE_COLOR enables colors in auto mode without TTY - NO_COLOR takes precedence over FORCE_COLOR - All semantic methods respect enabled/disabled state - get_color_mode() handles all edge cases Includes 15 integration tests for cross-command color consistency. --- tests/cli/test_cli_colors_integration.py | 197 +++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 tests/cli/test_cli_colors_integration.py diff --git a/tests/cli/test_cli_colors_integration.py b/tests/cli/test_cli_colors_integration.py new file mode 100644 index 0000000000..d66caa94e7 --- /dev/null +++ b/tests/cli/test_cli_colors_integration.py @@ -0,0 +1,197 @@ +"""Integration tests for CLI color output across all commands.""" + +from __future__ import annotations + +import sys +import typing as t + +import pytest + +from tmuxp.cli._colors import ColorMode, Colors, get_color_mode + + +class TestColorFlagIntegration: + """Tests for --color flag integration across CLI.""" + + def test_color_flag_auto_with_tty(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Verify --color=auto enables colors when stdout is TTY.""" + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) + monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.delenv("FORCE_COLOR", raising=False) + + color_mode = get_color_mode("auto") + colors = Colors(color_mode) + assert colors._enabled is True + + def test_color_flag_auto_without_tty(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Verify --color=auto disables colors when stdout is not TTY.""" + monkeypatch.setattr(sys.stdout, "isatty", lambda: False) + monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.delenv("FORCE_COLOR", raising=False) + + color_mode = get_color_mode("auto") + colors = Colors(color_mode) + assert colors._enabled is False + + def test_color_flag_always_forces_colors( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Verify --color=always forces colors even without TTY.""" + monkeypatch.setattr(sys.stdout, "isatty", lambda: False) + monkeypatch.delenv("NO_COLOR", raising=False) + + color_mode = get_color_mode("always") + colors = Colors(color_mode) + assert colors._enabled is True + # Verify output contains ANSI codes + assert "\033[" in colors.success("test") + + def test_color_flag_never_disables_colors( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Verify --color=never disables colors even with TTY.""" + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) + monkeypatch.delenv("NO_COLOR", raising=False) + + color_mode = get_color_mode("never") + colors = Colors(color_mode) + assert colors._enabled is False + # Verify output is plain text + assert colors.success("test") == "test" + assert colors.error("test") == "test" + assert colors.warning("test") == "test" + + +class TestEnvironmentVariableIntegration: + """Tests for environment variable integration with color system.""" + + def test_no_color_env_overrides_always( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Verify NO_COLOR environment variable overrides --color=always.""" + monkeypatch.setenv("NO_COLOR", "1") + + color_mode = get_color_mode("always") + colors = Colors(color_mode) + assert colors._enabled is False + assert colors.success("test") == "test" + + def test_no_color_env_with_empty_value( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Verify empty NO_COLOR is ignored (per spec).""" + monkeypatch.setenv("NO_COLOR", "") + monkeypatch.delenv("FORCE_COLOR", raising=False) + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) + + colors = Colors(ColorMode.ALWAYS) + # Empty NO_COLOR should be ignored, colors should be enabled + assert colors._enabled is True + + def test_force_color_env_with_auto(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Verify FORCE_COLOR enables colors in auto mode without TTY.""" + monkeypatch.setattr(sys.stdout, "isatty", lambda: False) + monkeypatch.setenv("FORCE_COLOR", "1") + monkeypatch.delenv("NO_COLOR", raising=False) + + colors = Colors(ColorMode.AUTO) + assert colors._enabled is True + + def test_no_color_takes_precedence_over_force_color( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Verify NO_COLOR takes precedence over FORCE_COLOR.""" + monkeypatch.setenv("NO_COLOR", "1") + monkeypatch.setenv("FORCE_COLOR", "1") + + colors = Colors(ColorMode.ALWAYS) + assert colors._enabled is False + + +class TestColorModeConsistency: + """Tests verifying consistent color behavior across commands.""" + + @pytest.fixture() + def colors_enabled(self, monkeypatch: pytest.MonkeyPatch) -> Colors: + """Create Colors with guaranteed enabled state.""" + monkeypatch.delenv("NO_COLOR", raising=False) + return Colors(ColorMode.ALWAYS) + + @pytest.fixture() + def colors_disabled(self) -> Colors: + """Create Colors with guaranteed disabled state.""" + return Colors(ColorMode.NEVER) + + def test_all_semantic_methods_respect_enabled_state( + self, colors_enabled: Colors + ) -> None: + """Verify all semantic color methods include ANSI codes when enabled.""" + methods: list[t.Callable[..., str]] = [ + colors_enabled.success, + colors_enabled.error, + colors_enabled.warning, + colors_enabled.info, + colors_enabled.muted, + ] + for method in methods: + result = method("test") + assert "\033[" in result, f"{method.__name__} should include ANSI codes" + assert result.endswith("\033[0m"), f"{method.__name__} should reset color" + + def test_all_semantic_methods_respect_disabled_state( + self, colors_disabled: Colors + ) -> None: + """Verify all semantic color methods return plain text when disabled.""" + methods: list[t.Callable[..., str]] = [ + colors_disabled.success, + colors_disabled.error, + colors_disabled.warning, + colors_disabled.info, + colors_disabled.muted, + ] + for method in methods: + result = method("test") + assert result == "test", f"{method.__name__} should return plain text" + assert "\033[" not in result, ( + f"{method.__name__} should not have ANSI codes" + ) + + def test_highlight_bold_parameter(self, colors_enabled: Colors) -> None: + """Verify highlight respects bold parameter.""" + with_bold = colors_enabled.highlight("test", bold=True) + without_bold = colors_enabled.highlight("test", bold=False) + + assert "\033[1m" in with_bold + assert "\033[1m" not in without_bold + # Both should have magenta + assert "\033[35m" in with_bold + assert "\033[35m" in without_bold + + +class TestGetColorModeFunction: + """Tests for get_color_mode helper function.""" + + def test_none_defaults_to_auto(self) -> None: + """Verify None input returns AUTO mode.""" + assert get_color_mode(None) == ColorMode.AUTO + + def test_valid_string_values(self) -> None: + """Verify all valid string values are converted correctly.""" + assert get_color_mode("auto") == ColorMode.AUTO + assert get_color_mode("always") == ColorMode.ALWAYS + assert get_color_mode("never") == ColorMode.NEVER + + def test_case_insensitive(self) -> None: + """Verify string values are case insensitive.""" + assert get_color_mode("AUTO") == ColorMode.AUTO + assert get_color_mode("Always") == ColorMode.ALWAYS + assert get_color_mode("NEVER") == ColorMode.NEVER + assert get_color_mode("aUtO") == ColorMode.AUTO + + def test_invalid_values_fallback_to_auto(self) -> None: + """Verify invalid values fallback to AUTO mode.""" + assert get_color_mode("invalid") == ColorMode.AUTO + assert get_color_mode("yes") == ColorMode.AUTO + assert get_color_mode("no") == ColorMode.AUTO + assert get_color_mode("true") == ColorMode.AUTO + assert get_color_mode("") == ColorMode.AUTO From 0bde7009c7c538dd533e2ce7aad0306a098f970a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 3 Jan 2026 11:41:00 -0600 Subject: [PATCH 09/99] feat(cli/utils): Add semantic colors to interactive prompts Apply semantic color system to prompt utilities: - prompt(): default value [path] uses info() (cyan) - prompt_bool(): choice indicator [Y/n] uses muted() (blue) - prompt_choices(): options list (a, b) uses muted(), default uses info() Fix circular import between utils.py and _colors.py by using lazy import of style() inside _colorize() method. Includes 7 function-based tests for prompt color output. --- src/tmuxp/cli/_colors.py | 5 +- src/tmuxp/cli/utils.py | 16 ++++-- tests/cli/test_prompt_colors.py | 93 +++++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 5 deletions(-) create mode 100644 tests/cli/test_prompt_colors.py diff --git a/src/tmuxp/cli/_colors.py b/src/tmuxp/cli/_colors.py index d5521f9f9a..7a9a087f88 100644 --- a/src/tmuxp/cli/_colors.py +++ b/src/tmuxp/cli/_colors.py @@ -34,8 +34,6 @@ import os import sys -from .utils import style - class ColorMode(enum.Enum): """Color output modes for CLI. @@ -178,6 +176,9 @@ def _colorize(self, text: str, fg: str, bold: bool = False) -> str: Colorized text if enabled, plain text otherwise. """ if self._enabled: + # Lazy import to avoid circular dependency with utils.py + from .utils import style + return style(text, fg=fg, bold=bold) return text diff --git a/src/tmuxp/cli/utils.py b/src/tmuxp/cli/utils.py index 68de22f7c0..64abb9b46a 100644 --- a/src/tmuxp/cli/utils.py +++ b/src/tmuxp/cli/utils.py @@ -8,6 +8,8 @@ from tmuxp import log +from ._colors import ColorMode, Colors + if t.TYPE_CHECKING: from collections.abc import Callable, Sequence from typing import TypeAlias @@ -59,7 +61,8 @@ def prompt( `flask-script `_. See the `flask-script license `_. """ - prompt_ = name + ((default and f" [{default}]") or "") + colors = Colors(ColorMode.AUTO) + prompt_ = name + ((default and " " + colors.info(f"[{default}]")) or "") prompt_ += (name.endswith("?") and " ") or ": " while True: rv = input(prompt_) or default @@ -99,6 +102,7 @@ def prompt_bool( ------- bool """ + colors = Colors(ColorMode.AUTO) yes_choices = yes_choices or ("y", "yes", "1", "on", "true", "t") no_choices = no_choices or ("n", "no", "0", "off", "false", "f") @@ -109,7 +113,7 @@ def prompt_bool( else: prompt_choice = "y/N" - prompt_ = name + f" [{prompt_choice}]" + prompt_ = name + " " + colors.muted(f"[{prompt_choice}]") prompt_ += (name.endswith("?") and " ") or ": " while True: @@ -151,6 +155,7 @@ def prompt_choices( ------- str """ + colors = Colors(ColorMode.AUTO) choices_: list[str] = [] options: list[str] = [] @@ -162,8 +167,13 @@ def prompt_choices( choice = choice[0] choices_.append(choice) + choices_str = colors.muted(f"({', '.join(options)})") + default_str = " " + colors.info(f"[{default}]") if default else "" + prompt_text = f"{name} - {choices_str}{default_str}" + while True: - rv = prompt(name + " - ({})".format(", ".join(options)), default=default) + prompt_ = prompt_text + ": " + rv = input(prompt_) or default if not rv or rv == default: return default rv = rv.lower() diff --git a/tests/cli/test_prompt_colors.py b/tests/cli/test_prompt_colors.py new file mode 100644 index 0000000000..3fc57be49a --- /dev/null +++ b/tests/cli/test_prompt_colors.py @@ -0,0 +1,93 @@ +"""Tests for colored prompt utilities.""" + +from __future__ import annotations + +import pytest + +from tmuxp.cli._colors import ColorMode, Colors + + +def test_prompt_bool_choice_indicator_muted(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify [Y/n] uses muted color (blue).""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + # Test the muted color is applied to choice indicators + result = colors.muted("[Y/n]") + assert "\033[34m" in result # blue foreground + assert "[Y/n]" in result + assert result.endswith("\033[0m") + + +def test_prompt_bool_choice_indicator_variants( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify all choice indicator variants are colored.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + for indicator in ["[Y/n]", "[y/N]", "[y/n]"]: + result = colors.muted(indicator) + assert "\033[34m" in result + assert indicator in result + + +def test_prompt_default_value_uses_info(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify default path uses info color (cyan).""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + path = "/home/user/.tmuxp/session.yaml" + result = colors.info(f"[{path}]") + assert "\033[36m" in result # cyan foreground + assert path in result + assert result.endswith("\033[0m") + + +def test_prompt_choices_list_muted(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify (yaml, json) uses muted color (blue).""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + choices = "(yaml, json)" + result = colors.muted(choices) + assert "\033[34m" in result # blue foreground + assert choices in result + + +def test_prompts_respect_no_color_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify NO_COLOR disables prompt colors.""" + monkeypatch.setenv("NO_COLOR", "1") + colors = Colors(ColorMode.AUTO) + + assert colors.muted("[Y/n]") == "[Y/n]" + assert colors.info("[default]") == "[default]" + + +def test_prompt_combined_format(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify combined prompt format with choices and default.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + name = "Convert to" + choices_str = colors.muted("(yaml, json)") + default_str = colors.info("[yaml]") + prompt = f"{name} - {choices_str} {default_str}" + + # Should contain both blue (muted) and cyan (info) ANSI codes + assert "\033[34m" in prompt # blue for choices + assert "\033[36m" in prompt # cyan for default + assert "Convert to" in prompt + assert "yaml, json" in prompt + + +def test_prompt_colors_disabled_returns_plain_text( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify disabled colors return plain text without ANSI codes.""" + colors = Colors(ColorMode.NEVER) + + assert colors.muted("[Y/n]") == "[Y/n]" + assert colors.info("[/path/to/file]") == "[/path/to/file]" + assert "\033[" not in colors.muted("test") + assert "\033[" not in colors.info("test") From 521d4ca98d3008c1f1bb3f01d99a0565e6698bcc Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 3 Jan 2026 11:51:02 -0600 Subject: [PATCH 10/99] tests(cli[colors]): Convert class-based tests to functions Follow project conventions per AGENTS.md - use function-based tests instead of class-based tests for color modules. Converted files: - test_colors.py - test_cli_colors_integration.py - test_convert_colors.py - test_edit_colors.py - test_freeze_colors.py - test_import_colors.py - test_shell_colors.py --- tests/cli/test_cli_colors_integration.py | 365 ++++++++++---------- tests/cli/test_colors.py | 421 ++++++++++++----------- tests/cli/test_convert_colors.py | 164 +++++---- tests/cli/test_edit_colors.py | 160 +++++---- tests/cli/test_freeze_colors.py | 198 ++++++----- tests/cli/test_import_colors.py | 208 ++++++----- tests/cli/test_shell_colors.py | 200 ++++++----- 7 files changed, 909 insertions(+), 807 deletions(-) diff --git a/tests/cli/test_cli_colors_integration.py b/tests/cli/test_cli_colors_integration.py index d66caa94e7..2eab9d37af 100644 --- a/tests/cli/test_cli_colors_integration.py +++ b/tests/cli/test_cli_colors_integration.py @@ -9,189 +9,184 @@ from tmuxp.cli._colors import ColorMode, Colors, get_color_mode +# Color flag integration tests -class TestColorFlagIntegration: - """Tests for --color flag integration across CLI.""" - - def test_color_flag_auto_with_tty(self, monkeypatch: pytest.MonkeyPatch) -> None: - """Verify --color=auto enables colors when stdout is TTY.""" - monkeypatch.setattr(sys.stdout, "isatty", lambda: True) - monkeypatch.delenv("NO_COLOR", raising=False) - monkeypatch.delenv("FORCE_COLOR", raising=False) - - color_mode = get_color_mode("auto") - colors = Colors(color_mode) - assert colors._enabled is True - - def test_color_flag_auto_without_tty(self, monkeypatch: pytest.MonkeyPatch) -> None: - """Verify --color=auto disables colors when stdout is not TTY.""" - monkeypatch.setattr(sys.stdout, "isatty", lambda: False) - monkeypatch.delenv("NO_COLOR", raising=False) - monkeypatch.delenv("FORCE_COLOR", raising=False) - - color_mode = get_color_mode("auto") - colors = Colors(color_mode) - assert colors._enabled is False - - def test_color_flag_always_forces_colors( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - """Verify --color=always forces colors even without TTY.""" - monkeypatch.setattr(sys.stdout, "isatty", lambda: False) - monkeypatch.delenv("NO_COLOR", raising=False) - - color_mode = get_color_mode("always") - colors = Colors(color_mode) - assert colors._enabled is True - # Verify output contains ANSI codes - assert "\033[" in colors.success("test") - - def test_color_flag_never_disables_colors( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - """Verify --color=never disables colors even with TTY.""" - monkeypatch.setattr(sys.stdout, "isatty", lambda: True) - monkeypatch.delenv("NO_COLOR", raising=False) - - color_mode = get_color_mode("never") - colors = Colors(color_mode) - assert colors._enabled is False - # Verify output is plain text - assert colors.success("test") == "test" - assert colors.error("test") == "test" - assert colors.warning("test") == "test" - - -class TestEnvironmentVariableIntegration: - """Tests for environment variable integration with color system.""" - - def test_no_color_env_overrides_always( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - """Verify NO_COLOR environment variable overrides --color=always.""" - monkeypatch.setenv("NO_COLOR", "1") - - color_mode = get_color_mode("always") - colors = Colors(color_mode) - assert colors._enabled is False - assert colors.success("test") == "test" - - def test_no_color_env_with_empty_value( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - """Verify empty NO_COLOR is ignored (per spec).""" - monkeypatch.setenv("NO_COLOR", "") - monkeypatch.delenv("FORCE_COLOR", raising=False) - monkeypatch.setattr(sys.stdout, "isatty", lambda: True) - - colors = Colors(ColorMode.ALWAYS) - # Empty NO_COLOR should be ignored, colors should be enabled - assert colors._enabled is True - - def test_force_color_env_with_auto(self, monkeypatch: pytest.MonkeyPatch) -> None: - """Verify FORCE_COLOR enables colors in auto mode without TTY.""" - monkeypatch.setattr(sys.stdout, "isatty", lambda: False) - monkeypatch.setenv("FORCE_COLOR", "1") - monkeypatch.delenv("NO_COLOR", raising=False) - - colors = Colors(ColorMode.AUTO) - assert colors._enabled is True - - def test_no_color_takes_precedence_over_force_color( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - """Verify NO_COLOR takes precedence over FORCE_COLOR.""" - monkeypatch.setenv("NO_COLOR", "1") - monkeypatch.setenv("FORCE_COLOR", "1") - - colors = Colors(ColorMode.ALWAYS) - assert colors._enabled is False - - -class TestColorModeConsistency: - """Tests verifying consistent color behavior across commands.""" - - @pytest.fixture() - def colors_enabled(self, monkeypatch: pytest.MonkeyPatch) -> Colors: - """Create Colors with guaranteed enabled state.""" - monkeypatch.delenv("NO_COLOR", raising=False) - return Colors(ColorMode.ALWAYS) - - @pytest.fixture() - def colors_disabled(self) -> Colors: - """Create Colors with guaranteed disabled state.""" - return Colors(ColorMode.NEVER) - - def test_all_semantic_methods_respect_enabled_state( - self, colors_enabled: Colors - ) -> None: - """Verify all semantic color methods include ANSI codes when enabled.""" - methods: list[t.Callable[..., str]] = [ - colors_enabled.success, - colors_enabled.error, - colors_enabled.warning, - colors_enabled.info, - colors_enabled.muted, - ] - for method in methods: - result = method("test") - assert "\033[" in result, f"{method.__name__} should include ANSI codes" - assert result.endswith("\033[0m"), f"{method.__name__} should reset color" - - def test_all_semantic_methods_respect_disabled_state( - self, colors_disabled: Colors - ) -> None: - """Verify all semantic color methods return plain text when disabled.""" - methods: list[t.Callable[..., str]] = [ - colors_disabled.success, - colors_disabled.error, - colors_disabled.warning, - colors_disabled.info, - colors_disabled.muted, - ] - for method in methods: - result = method("test") - assert result == "test", f"{method.__name__} should return plain text" - assert "\033[" not in result, ( - f"{method.__name__} should not have ANSI codes" - ) - - def test_highlight_bold_parameter(self, colors_enabled: Colors) -> None: - """Verify highlight respects bold parameter.""" - with_bold = colors_enabled.highlight("test", bold=True) - without_bold = colors_enabled.highlight("test", bold=False) - - assert "\033[1m" in with_bold - assert "\033[1m" not in without_bold - # Both should have magenta - assert "\033[35m" in with_bold - assert "\033[35m" in without_bold - - -class TestGetColorModeFunction: - """Tests for get_color_mode helper function.""" - - def test_none_defaults_to_auto(self) -> None: - """Verify None input returns AUTO mode.""" - assert get_color_mode(None) == ColorMode.AUTO - - def test_valid_string_values(self) -> None: - """Verify all valid string values are converted correctly.""" - assert get_color_mode("auto") == ColorMode.AUTO - assert get_color_mode("always") == ColorMode.ALWAYS - assert get_color_mode("never") == ColorMode.NEVER - - def test_case_insensitive(self) -> None: - """Verify string values are case insensitive.""" - assert get_color_mode("AUTO") == ColorMode.AUTO - assert get_color_mode("Always") == ColorMode.ALWAYS - assert get_color_mode("NEVER") == ColorMode.NEVER - assert get_color_mode("aUtO") == ColorMode.AUTO - - def test_invalid_values_fallback_to_auto(self) -> None: - """Verify invalid values fallback to AUTO mode.""" - assert get_color_mode("invalid") == ColorMode.AUTO - assert get_color_mode("yes") == ColorMode.AUTO - assert get_color_mode("no") == ColorMode.AUTO - assert get_color_mode("true") == ColorMode.AUTO - assert get_color_mode("") == ColorMode.AUTO + +def test_color_flag_auto_with_tty(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify --color=auto enables colors when stdout is TTY.""" + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) + monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.delenv("FORCE_COLOR", raising=False) + + color_mode = get_color_mode("auto") + colors = Colors(color_mode) + assert colors._enabled is True + + +def test_color_flag_auto_without_tty(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify --color=auto disables colors when stdout is not TTY.""" + monkeypatch.setattr(sys.stdout, "isatty", lambda: False) + monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.delenv("FORCE_COLOR", raising=False) + + color_mode = get_color_mode("auto") + colors = Colors(color_mode) + assert colors._enabled is False + + +def test_color_flag_always_forces_colors(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify --color=always forces colors even without TTY.""" + monkeypatch.setattr(sys.stdout, "isatty", lambda: False) + monkeypatch.delenv("NO_COLOR", raising=False) + + color_mode = get_color_mode("always") + colors = Colors(color_mode) + assert colors._enabled is True + # Verify output contains ANSI codes + assert "\033[" in colors.success("test") + + +def test_color_flag_never_disables_colors(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify --color=never disables colors even with TTY.""" + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) + monkeypatch.delenv("NO_COLOR", raising=False) + + color_mode = get_color_mode("never") + colors = Colors(color_mode) + assert colors._enabled is False + # Verify output is plain text + assert colors.success("test") == "test" + assert colors.error("test") == "test" + assert colors.warning("test") == "test" + + +# Environment variable integration tests + + +def test_no_color_env_overrides_always(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify NO_COLOR environment variable overrides --color=always.""" + monkeypatch.setenv("NO_COLOR", "1") + + color_mode = get_color_mode("always") + colors = Colors(color_mode) + assert colors._enabled is False + assert colors.success("test") == "test" + + +def test_no_color_env_with_empty_value(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify empty NO_COLOR is ignored (per spec).""" + monkeypatch.setenv("NO_COLOR", "") + monkeypatch.delenv("FORCE_COLOR", raising=False) + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) + + colors = Colors(ColorMode.ALWAYS) + # Empty NO_COLOR should be ignored, colors should be enabled + assert colors._enabled is True + + +def test_force_color_env_with_auto(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify FORCE_COLOR enables colors in auto mode without TTY.""" + monkeypatch.setattr(sys.stdout, "isatty", lambda: False) + monkeypatch.setenv("FORCE_COLOR", "1") + monkeypatch.delenv("NO_COLOR", raising=False) + + colors = Colors(ColorMode.AUTO) + assert colors._enabled is True + + +def test_no_color_takes_precedence_over_force_color( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify NO_COLOR takes precedence over FORCE_COLOR.""" + monkeypatch.setenv("NO_COLOR", "1") + monkeypatch.setenv("FORCE_COLOR", "1") + + colors = Colors(ColorMode.ALWAYS) + assert colors._enabled is False + + +# Color mode consistency tests + + +def test_all_semantic_methods_respect_enabled_state( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify all semantic color methods include ANSI codes when enabled.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + methods: list[t.Callable[..., str]] = [ + colors.success, + colors.error, + colors.warning, + colors.info, + colors.muted, + ] + for method in methods: + result = method("test") + assert "\033[" in result, f"{method.__name__} should include ANSI codes" + assert result.endswith("\033[0m"), f"{method.__name__} should reset color" + + +def test_all_semantic_methods_respect_disabled_state() -> None: + """Verify all semantic color methods return plain text when disabled.""" + colors = Colors(ColorMode.NEVER) + + methods: list[t.Callable[..., str]] = [ + colors.success, + colors.error, + colors.warning, + colors.info, + colors.muted, + ] + for method in methods: + result = method("test") + assert result == "test", f"{method.__name__} should return plain text" + assert "\033[" not in result, f"{method.__name__} should not have ANSI codes" + + +def test_highlight_bold_parameter(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify highlight respects bold parameter.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + with_bold = colors.highlight("test", bold=True) + without_bold = colors.highlight("test", bold=False) + + assert "\033[1m" in with_bold + assert "\033[1m" not in without_bold + # Both should have magenta + assert "\033[35m" in with_bold + assert "\033[35m" in without_bold + + +# get_color_mode function tests + + +def test_get_color_mode_none_defaults_to_auto() -> None: + """Verify None input returns AUTO mode.""" + assert get_color_mode(None) == ColorMode.AUTO + + +def test_get_color_mode_valid_string_values() -> None: + """Verify all valid string values are converted correctly.""" + assert get_color_mode("auto") == ColorMode.AUTO + assert get_color_mode("always") == ColorMode.ALWAYS + assert get_color_mode("never") == ColorMode.NEVER + + +def test_get_color_mode_case_insensitive() -> None: + """Verify string values are case insensitive.""" + assert get_color_mode("AUTO") == ColorMode.AUTO + assert get_color_mode("Always") == ColorMode.ALWAYS + assert get_color_mode("NEVER") == ColorMode.NEVER + assert get_color_mode("aUtO") == ColorMode.AUTO + + +def test_get_color_mode_invalid_values_fallback_to_auto() -> None: + """Verify invalid values fallback to AUTO mode.""" + assert get_color_mode("invalid") == ColorMode.AUTO + assert get_color_mode("yes") == ColorMode.AUTO + assert get_color_mode("no") == ColorMode.AUTO + assert get_color_mode("true") == ColorMode.AUTO + assert get_color_mode("") == ColorMode.AUTO diff --git a/tests/cli/test_colors.py b/tests/cli/test_colors.py index d327f0eba4..7e75a8e26a 100644 --- a/tests/cli/test_colors.py +++ b/tests/cli/test_colors.py @@ -8,201 +8,228 @@ from tmuxp.cli._colors import ColorMode, Colors, get_color_mode +# ColorMode tests + -class TestColorMode: - """Tests for ColorMode enum and color detection.""" - - def test_auto_tty_enabled(self, monkeypatch: pytest.MonkeyPatch) -> None: - """Colors enabled when stdout is TTY in AUTO mode.""" - monkeypatch.setattr(sys.stdout, "isatty", lambda: True) - monkeypatch.delenv("NO_COLOR", raising=False) - monkeypatch.delenv("FORCE_COLOR", raising=False) - colors = Colors(ColorMode.AUTO) - assert colors._enabled is True - - def test_auto_no_tty_disabled(self, monkeypatch: pytest.MonkeyPatch) -> None: - """Colors disabled when stdout is not TTY in AUTO mode.""" - monkeypatch.setattr(sys.stdout, "isatty", lambda: False) - monkeypatch.delenv("NO_COLOR", raising=False) - monkeypatch.delenv("FORCE_COLOR", raising=False) - colors = Colors(ColorMode.AUTO) - assert colors._enabled is False - - def test_no_color_env_respected(self, monkeypatch: pytest.MonkeyPatch) -> None: - """NO_COLOR environment variable disables colors even in ALWAYS mode.""" - monkeypatch.setenv("NO_COLOR", "1") - colors = Colors(ColorMode.ALWAYS) - assert colors._enabled is False - - def test_no_color_any_value(self, monkeypatch: pytest.MonkeyPatch) -> None: - """NO_COLOR with any non-empty value disables colors.""" - monkeypatch.setenv("NO_COLOR", "yes") - colors = Colors(ColorMode.ALWAYS) - assert colors._enabled is False - - def test_force_color_env_respected(self, monkeypatch: pytest.MonkeyPatch) -> None: - """FORCE_COLOR environment variable enables colors in AUTO mode without TTY.""" - monkeypatch.setattr(sys.stdout, "isatty", lambda: False) - monkeypatch.setenv("FORCE_COLOR", "1") - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.AUTO) - assert colors._enabled is True - - def test_no_color_takes_precedence(self, monkeypatch: pytest.MonkeyPatch) -> None: - """NO_COLOR takes precedence over FORCE_COLOR.""" - monkeypatch.setenv("NO_COLOR", "1") - monkeypatch.setenv("FORCE_COLOR", "1") - colors = Colors(ColorMode.ALWAYS) - assert colors._enabled is False - - def test_never_mode_disables(self, monkeypatch: pytest.MonkeyPatch) -> None: - """ColorMode.NEVER always disables colors.""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.NEVER) - assert colors._enabled is False - # Output should be plain text without ANSI codes - assert colors.success("test") == "test" - assert colors.error("fail") == "fail" - assert colors.warning("warn") == "warn" - assert colors.info("info") == "info" - assert colors.highlight("hl") == "hl" - assert colors.muted("mute") == "mute" - - def test_always_mode_enables(self, monkeypatch: pytest.MonkeyPatch) -> None: - """ColorMode.ALWAYS enables colors (unless NO_COLOR set).""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - assert colors._enabled is True - # Output should contain ANSI escape codes - assert "\033[" in colors.success("test") - - -class TestSemanticColors: - """Tests for semantic color methods.""" - - @pytest.fixture() - def colors(self, monkeypatch: pytest.MonkeyPatch) -> Colors: - """Create a Colors instance with colors enabled.""" - monkeypatch.delenv("NO_COLOR", raising=False) - return Colors(ColorMode.ALWAYS) - - def test_success_applies_green(self, colors: Colors) -> None: - """success() applies green color.""" - result = colors.success("ok") - assert "\033[32m" in result # green foreground - assert "ok" in result - assert result.endswith("\033[0m") # reset at end - - def test_error_applies_red(self, colors: Colors) -> None: - """error() applies red color.""" - result = colors.error("fail") - assert "\033[31m" in result # red foreground - assert "fail" in result - - def test_warning_applies_yellow(self, colors: Colors) -> None: - """warning() applies yellow color.""" - result = colors.warning("caution") - assert "\033[33m" in result # yellow foreground - assert "caution" in result - - def test_info_applies_cyan(self, colors: Colors) -> None: - """info() applies cyan color.""" - result = colors.info("message") - assert "\033[36m" in result # cyan foreground - assert "message" in result - - def test_highlight_applies_magenta_bold(self, colors: Colors) -> None: - """highlight() applies magenta color with bold by default.""" - result = colors.highlight("important") - assert "\033[35m" in result # magenta foreground - assert "\033[1m" in result # bold - assert "important" in result - - def test_highlight_no_bold(self, colors: Colors) -> None: - """highlight() can be used without bold.""" - result = colors.highlight("important", bold=False) - assert "\033[35m" in result # magenta foreground - assert "\033[1m" not in result # no bold - assert "important" in result - - def test_muted_applies_blue(self, colors: Colors) -> None: - """muted() applies blue color without bold.""" - result = colors.muted("secondary") - assert "\033[34m" in result # blue foreground - assert "\033[1m" not in result # never bold - assert "secondary" in result - - def test_success_with_bold(self, colors: Colors) -> None: - """success() can be used with bold.""" - result = colors.success("done", bold=True) - assert "\033[32m" in result # green - assert "\033[1m" in result # bold - assert "done" in result - - -class TestGetColorMode: - """Tests for get_color_mode function.""" - - def test_none_returns_auto(self) -> None: - """None argument returns AUTO mode.""" - assert get_color_mode(None) == ColorMode.AUTO - - def test_auto_string(self) -> None: - """'auto' string returns AUTO mode.""" - assert get_color_mode("auto") == ColorMode.AUTO - - def test_always_string(self) -> None: - """'always' string returns ALWAYS mode.""" - assert get_color_mode("always") == ColorMode.ALWAYS - - def test_never_string(self) -> None: - """'never' string returns NEVER mode.""" - assert get_color_mode("never") == ColorMode.NEVER - - def test_case_insensitive(self) -> None: - """Color mode strings are case insensitive.""" - assert get_color_mode("ALWAYS") == ColorMode.ALWAYS - assert get_color_mode("Never") == ColorMode.NEVER - assert get_color_mode("AUTO") == ColorMode.AUTO - - def test_invalid_returns_auto(self) -> None: - """Invalid color mode strings return AUTO as fallback.""" - assert get_color_mode("invalid") == ColorMode.AUTO - assert get_color_mode("yes") == ColorMode.AUTO - assert get_color_mode("") == ColorMode.AUTO - - -class TestColorsClassAttributes: - """Tests for Colors class color name attributes.""" - - def test_semantic_color_names(self) -> None: - """Verify semantic color name attributes exist.""" - assert Colors.SUCCESS == "green" - assert Colors.WARNING == "yellow" - assert Colors.ERROR == "red" - assert Colors.INFO == "cyan" - assert Colors.HIGHLIGHT == "magenta" - assert Colors.MUTED == "blue" - - -class TestColorsDisabled: - """Tests for Colors behavior when disabled.""" - - def test_disabled_returns_plain_text(self) -> None: - """When colors are disabled, methods return plain text.""" - colors = Colors(ColorMode.NEVER) - assert colors.success("text") == "text" - assert colors.error("text") == "text" - assert colors.warning("text") == "text" - assert colors.info("text") == "text" - assert colors.highlight("text") == "text" - assert colors.muted("text") == "text" - - def test_disabled_preserves_text(self) -> None: - """Disabled colors preserve special characters.""" - colors = Colors(ColorMode.NEVER) - special = "path/to/file.yaml" - assert colors.info(special) == special - - with_spaces = "some message" - assert colors.success(with_spaces) == with_spaces +def test_auto_tty_enabled(monkeypatch: pytest.MonkeyPatch) -> None: + """Colors enabled when stdout is TTY in AUTO mode.""" + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) + monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.delenv("FORCE_COLOR", raising=False) + colors = Colors(ColorMode.AUTO) + assert colors._enabled is True + + +def test_auto_no_tty_disabled(monkeypatch: pytest.MonkeyPatch) -> None: + """Colors disabled when stdout is not TTY in AUTO mode.""" + monkeypatch.setattr(sys.stdout, "isatty", lambda: False) + monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.delenv("FORCE_COLOR", raising=False) + colors = Colors(ColorMode.AUTO) + assert colors._enabled is False + + +def test_no_color_env_respected(monkeypatch: pytest.MonkeyPatch) -> None: + """NO_COLOR environment variable disables colors even in ALWAYS mode.""" + monkeypatch.setenv("NO_COLOR", "1") + colors = Colors(ColorMode.ALWAYS) + assert colors._enabled is False + + +def test_no_color_any_value(monkeypatch: pytest.MonkeyPatch) -> None: + """NO_COLOR with any non-empty value disables colors.""" + monkeypatch.setenv("NO_COLOR", "yes") + colors = Colors(ColorMode.ALWAYS) + assert colors._enabled is False + + +def test_force_color_env_respected(monkeypatch: pytest.MonkeyPatch) -> None: + """FORCE_COLOR environment variable enables colors in AUTO mode without TTY.""" + monkeypatch.setattr(sys.stdout, "isatty", lambda: False) + monkeypatch.setenv("FORCE_COLOR", "1") + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.AUTO) + assert colors._enabled is True + + +def test_no_color_takes_precedence(monkeypatch: pytest.MonkeyPatch) -> None: + """NO_COLOR takes precedence over FORCE_COLOR.""" + monkeypatch.setenv("NO_COLOR", "1") + monkeypatch.setenv("FORCE_COLOR", "1") + colors = Colors(ColorMode.ALWAYS) + assert colors._enabled is False + + +def test_never_mode_disables(monkeypatch: pytest.MonkeyPatch) -> None: + """ColorMode.NEVER always disables colors.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.NEVER) + assert colors._enabled is False + assert colors.success("test") == "test" + assert colors.error("fail") == "fail" + assert colors.warning("warn") == "warn" + assert colors.info("info") == "info" + assert colors.highlight("hl") == "hl" + assert colors.muted("mute") == "mute" + + +def test_always_mode_enables(monkeypatch: pytest.MonkeyPatch) -> None: + """ColorMode.ALWAYS enables colors (unless NO_COLOR set).""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + assert colors._enabled is True + assert "\033[" in colors.success("test") + + +# Semantic color tests + + +def test_success_applies_green(monkeypatch: pytest.MonkeyPatch) -> None: + """success() applies green color.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + result = colors.success("ok") + assert "\033[32m" in result + assert "ok" in result + assert result.endswith("\033[0m") + + +def test_error_applies_red(monkeypatch: pytest.MonkeyPatch) -> None: + """error() applies red color.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + result = colors.error("fail") + assert "\033[31m" in result + assert "fail" in result + + +def test_warning_applies_yellow(monkeypatch: pytest.MonkeyPatch) -> None: + """warning() applies yellow color.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + result = colors.warning("caution") + assert "\033[33m" in result + assert "caution" in result + + +def test_info_applies_cyan(monkeypatch: pytest.MonkeyPatch) -> None: + """info() applies cyan color.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + result = colors.info("message") + assert "\033[36m" in result + assert "message" in result + + +def test_highlight_applies_magenta_bold(monkeypatch: pytest.MonkeyPatch) -> None: + """highlight() applies magenta color with bold by default.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + result = colors.highlight("important") + assert "\033[35m" in result + assert "\033[1m" in result + assert "important" in result + + +def test_highlight_no_bold(monkeypatch: pytest.MonkeyPatch) -> None: + """highlight() can be used without bold.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + result = colors.highlight("important", bold=False) + assert "\033[35m" in result + assert "\033[1m" not in result + assert "important" in result + + +def test_muted_applies_blue(monkeypatch: pytest.MonkeyPatch) -> None: + """muted() applies blue color without bold.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + result = colors.muted("secondary") + assert "\033[34m" in result + assert "\033[1m" not in result + assert "secondary" in result + + +def test_success_with_bold(monkeypatch: pytest.MonkeyPatch) -> None: + """success() can be used with bold.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + result = colors.success("done", bold=True) + assert "\033[32m" in result + assert "\033[1m" in result + assert "done" in result + + +# get_color_mode tests + + +def test_get_color_mode_none_returns_auto() -> None: + """None argument returns AUTO mode.""" + assert get_color_mode(None) == ColorMode.AUTO + + +def test_get_color_mode_auto_string() -> None: + """'auto' string returns AUTO mode.""" + assert get_color_mode("auto") == ColorMode.AUTO + + +def test_get_color_mode_always_string() -> None: + """'always' string returns ALWAYS mode.""" + assert get_color_mode("always") == ColorMode.ALWAYS + + +def test_get_color_mode_never_string() -> None: + """'never' string returns NEVER mode.""" + assert get_color_mode("never") == ColorMode.NEVER + + +def test_get_color_mode_case_insensitive() -> None: + """Color mode strings are case insensitive.""" + assert get_color_mode("ALWAYS") == ColorMode.ALWAYS + assert get_color_mode("Never") == ColorMode.NEVER + assert get_color_mode("AUTO") == ColorMode.AUTO + + +def test_get_color_mode_invalid_returns_auto() -> None: + """Invalid color mode strings return AUTO as fallback.""" + assert get_color_mode("invalid") == ColorMode.AUTO + assert get_color_mode("yes") == ColorMode.AUTO + assert get_color_mode("") == ColorMode.AUTO + + +# Colors class attribute tests + + +def test_semantic_color_names() -> None: + """Verify semantic color name attributes exist.""" + assert Colors.SUCCESS == "green" + assert Colors.WARNING == "yellow" + assert Colors.ERROR == "red" + assert Colors.INFO == "cyan" + assert Colors.HIGHLIGHT == "magenta" + assert Colors.MUTED == "blue" + + +# Colors disabled tests + + +def test_disabled_returns_plain_text() -> None: + """When colors are disabled, methods return plain text.""" + colors = Colors(ColorMode.NEVER) + assert colors.success("text") == "text" + assert colors.error("text") == "text" + assert colors.warning("text") == "text" + assert colors.info("text") == "text" + assert colors.highlight("text") == "text" + assert colors.muted("text") == "text" + + +def test_disabled_preserves_text() -> None: + """Disabled colors preserve special characters.""" + colors = Colors(ColorMode.NEVER) + special = "path/to/file.yaml" + assert colors.info(special) == special + + with_spaces = "some message" + assert colors.success(with_spaces) == with_spaces diff --git a/tests/cli/test_convert_colors.py b/tests/cli/test_convert_colors.py index 2ea8eb5202..0caa7b1d58 100644 --- a/tests/cli/test_convert_colors.py +++ b/tests/cli/test_convert_colors.py @@ -6,79 +6,93 @@ from tmuxp.cli._colors import ColorMode, Colors +# Convert command color output tests -class TestConvertColorOutput: - """Tests for convert command color output formatting.""" - - @pytest.fixture() - def colors(self, monkeypatch: pytest.MonkeyPatch) -> Colors: - """Create a Colors instance with colors enabled.""" - monkeypatch.delenv("NO_COLOR", raising=False) - return Colors(ColorMode.ALWAYS) - - @pytest.fixture() - def colors_disabled(self) -> Colors: - """Create a Colors instance with colors disabled.""" - return Colors(ColorMode.NEVER) - - def test_convert_success_message(self, colors: Colors) -> None: - """Verify success messages use success color (green).""" - result = colors.success("New workspace file saved to ") - assert "\033[32m" in result # green foreground - assert "New workspace file saved to" in result - - def test_convert_file_path_uses_info(self, colors: Colors) -> None: - """Verify file paths use info color (cyan).""" - path = "/path/to/config.yaml" - result = colors.info(path) - assert "\033[36m" in result # cyan foreground - assert path in result - - def test_convert_format_type_highlighted(self, colors: Colors) -> None: - """Verify format type uses highlight color (magenta + bold).""" - for fmt in ["json", "yaml"]: - result = colors.highlight(fmt) - assert "\033[35m" in result # magenta foreground - assert "\033[1m" in result # bold - assert fmt in result - - def test_convert_colors_disabled_plain_text(self, colors_disabled: Colors) -> None: - """Verify disabled colors return plain text.""" - assert colors_disabled.success("success") == "success" - assert colors_disabled.info("info") == "info" - assert colors_disabled.highlight("highlight") == "highlight" - - def test_convert_combined_success_format(self, colors: Colors) -> None: - """Verify combined success + info format for save message.""" - newfile = "/home/user/.tmuxp/session.json" - output = ( - colors.success("New workspace file saved to ") - + colors.info(f"<{newfile}>") - + "." - ) - # Should contain both green and cyan ANSI codes - assert "\033[32m" in output # green for success text - assert "\033[36m" in output # cyan for path - assert "New workspace file saved to" in output - assert newfile in output - assert output.endswith(".") - - def test_convert_prompt_format_with_highlight(self, colors: Colors) -> None: - """Verify prompt uses info for path and highlight for format.""" - workspace_file = "/path/to/config.yaml" - to_filetype = "json" - prompt = ( - f"Convert {colors.info(workspace_file)} to {colors.highlight(to_filetype)}?" - ) - assert "\033[36m" in prompt # cyan for file path - assert "\033[35m" in prompt # magenta for format type - assert workspace_file in prompt - assert to_filetype in prompt - - def test_convert_save_prompt_format(self, colors: Colors) -> None: - """Verify save prompt uses info color for new file path.""" - newfile = "/path/to/config.json" - prompt = f"Save workspace to {colors.info(newfile)}?" - assert "\033[36m" in prompt # cyan for file path - assert newfile in prompt - assert "Save workspace to" in prompt + +def test_convert_success_message(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify success messages use success color (green).""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + result = colors.success("New workspace file saved to ") + assert "\033[32m" in result # green foreground + assert "New workspace file saved to" in result + + +def test_convert_file_path_uses_info(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify file paths use info color (cyan).""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + path = "/path/to/config.yaml" + result = colors.info(path) + assert "\033[36m" in result # cyan foreground + assert path in result + + +def test_convert_format_type_highlighted(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify format type uses highlight color (magenta + bold).""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + for fmt in ["json", "yaml"]: + result = colors.highlight(fmt) + assert "\033[35m" in result # magenta foreground + assert "\033[1m" in result # bold + assert fmt in result + + +def test_convert_colors_disabled_plain_text() -> None: + """Verify disabled colors return plain text.""" + colors = Colors(ColorMode.NEVER) + + assert colors.success("success") == "success" + assert colors.info("info") == "info" + assert colors.highlight("highlight") == "highlight" + + +def test_convert_combined_success_format(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify combined success + info format for save message.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + newfile = "/home/user/.tmuxp/session.json" + output = ( + colors.success("New workspace file saved to ") + + colors.info(f"<{newfile}>") + + "." + ) + # Should contain both green and cyan ANSI codes + assert "\033[32m" in output # green for success text + assert "\033[36m" in output # cyan for path + assert "New workspace file saved to" in output + assert newfile in output + assert output.endswith(".") + + +def test_convert_prompt_format_with_highlight(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify prompt uses info for path and highlight for format.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + workspace_file = "/path/to/config.yaml" + to_filetype = "json" + prompt = ( + f"Convert {colors.info(workspace_file)} to {colors.highlight(to_filetype)}?" + ) + assert "\033[36m" in prompt # cyan for file path + assert "\033[35m" in prompt # magenta for format type + assert workspace_file in prompt + assert to_filetype in prompt + + +def test_convert_save_prompt_format(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify save prompt uses info color for new file path.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + newfile = "/path/to/config.json" + prompt = f"Save workspace to {colors.info(newfile)}?" + assert "\033[36m" in prompt # cyan for file path + assert newfile in prompt + assert "Save workspace to" in prompt diff --git a/tests/cli/test_edit_colors.py b/tests/cli/test_edit_colors.py index 80203083a8..db3d6bf065 100644 --- a/tests/cli/test_edit_colors.py +++ b/tests/cli/test_edit_colors.py @@ -6,79 +6,89 @@ from tmuxp.cli._colors import ColorMode, Colors +# Edit command color output tests -class TestEditColorOutput: - """Tests for edit command color output formatting.""" - - @pytest.fixture() - def colors(self, monkeypatch: pytest.MonkeyPatch) -> Colors: - """Create a Colors instance with colors enabled.""" - monkeypatch.delenv("NO_COLOR", raising=False) - return Colors(ColorMode.ALWAYS) - - @pytest.fixture() - def colors_disabled(self) -> Colors: - """Create a Colors instance with colors disabled.""" - return Colors(ColorMode.NEVER) - - def test_edit_opening_message_format(self, colors: Colors) -> None: - """Verify opening message format with file path and editor.""" - workspace_file = "/home/user/.tmuxp/dev.yaml" - editor = "vim" - output = ( - colors.muted("Opening ") - + colors.info(workspace_file) - + colors.muted(" in ") - + colors.highlight(editor, bold=False) - + colors.muted("...") - ) - # Should contain blue, cyan, and magenta ANSI codes - assert "\033[34m" in output # blue for muted - assert "\033[36m" in output # cyan for file path - assert "\033[35m" in output # magenta for editor - assert workspace_file in output - assert editor in output - - def test_edit_file_path_uses_info(self, colors: Colors) -> None: - """Verify file paths use info color (cyan).""" - path = "/path/to/workspace.yaml" - result = colors.info(path) - assert "\033[36m" in result # cyan foreground - assert path in result - - def test_edit_editor_highlighted(self, colors: Colors) -> None: - """Verify editor name uses highlight color without bold.""" - for editor in ["vim", "nano", "code", "emacs", "nvim"]: - result = colors.highlight(editor, bold=False) - assert "\033[35m" in result # magenta foreground - assert "\033[1m" not in result # no bold - subtle - assert editor in result - - def test_edit_muted_for_static_text(self, colors: Colors) -> None: - """Verify static text uses muted color (blue).""" - result = colors.muted("Opening ") - assert "\033[34m" in result # blue foreground - assert "Opening" in result - - def test_edit_colors_disabled_plain_text(self, colors_disabled: Colors) -> None: - """Verify disabled colors return plain text.""" - workspace_file = "/home/user/.tmuxp/dev.yaml" - editor = "vim" - output = ( - colors_disabled.muted("Opening ") - + colors_disabled.info(workspace_file) - + colors_disabled.muted(" in ") - + colors_disabled.highlight(editor, bold=False) - + colors_disabled.muted("...") - ) - # Should be plain text without ANSI codes - assert "\033[" not in output - assert output == f"Opening {workspace_file} in {editor}..." - - def test_edit_various_editors(self, colors: Colors) -> None: - """Verify common editors can be highlighted.""" - editors = ["vim", "nvim", "nano", "code", "emacs", "hx", "micro"] - for editor in editors: - result = colors.highlight(editor, bold=False) - assert "\033[35m" in result - assert editor in result + +def test_edit_opening_message_format(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify opening message format with file path and editor.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + workspace_file = "/home/user/.tmuxp/dev.yaml" + editor = "vim" + output = ( + colors.muted("Opening ") + + colors.info(workspace_file) + + colors.muted(" in ") + + colors.highlight(editor, bold=False) + + colors.muted("...") + ) + # Should contain blue, cyan, and magenta ANSI codes + assert "\033[34m" in output # blue for muted + assert "\033[36m" in output # cyan for file path + assert "\033[35m" in output # magenta for editor + assert workspace_file in output + assert editor in output + + +def test_edit_file_path_uses_info(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify file paths use info color (cyan).""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + path = "/path/to/workspace.yaml" + result = colors.info(path) + assert "\033[36m" in result # cyan foreground + assert path in result + + +def test_edit_editor_highlighted(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify editor name uses highlight color without bold.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + for editor in ["vim", "nano", "code", "emacs", "nvim"]: + result = colors.highlight(editor, bold=False) + assert "\033[35m" in result # magenta foreground + assert "\033[1m" not in result # no bold - subtle + assert editor in result + + +def test_edit_muted_for_static_text(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify static text uses muted color (blue).""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + result = colors.muted("Opening ") + assert "\033[34m" in result # blue foreground + assert "Opening" in result + + +def test_edit_colors_disabled_plain_text() -> None: + """Verify disabled colors return plain text.""" + colors = Colors(ColorMode.NEVER) + + workspace_file = "/home/user/.tmuxp/dev.yaml" + editor = "vim" + output = ( + colors.muted("Opening ") + + colors.info(workspace_file) + + colors.muted(" in ") + + colors.highlight(editor, bold=False) + + colors.muted("...") + ) + # Should be plain text without ANSI codes + assert "\033[" not in output + assert output == f"Opening {workspace_file} in {editor}..." + + +def test_edit_various_editors(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify common editors can be highlighted.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + editors = ["vim", "nvim", "nano", "code", "emacs", "hx", "micro"] + for editor in editors: + result = colors.highlight(editor, bold=False) + assert "\033[35m" in result + assert editor in result diff --git a/tests/cli/test_freeze_colors.py b/tests/cli/test_freeze_colors.py index ebefde84d0..fe00e3719b 100644 --- a/tests/cli/test_freeze_colors.py +++ b/tests/cli/test_freeze_colors.py @@ -6,93 +6,113 @@ from tmuxp.cli._colors import ColorMode, Colors +# Freeze command color output tests -class TestFreezeColorOutput: - """Tests for freeze command color output formatting.""" - - @pytest.fixture() - def colors(self, monkeypatch: pytest.MonkeyPatch) -> Colors: - """Create a Colors instance with colors enabled.""" - monkeypatch.delenv("NO_COLOR", raising=False) - return Colors(ColorMode.ALWAYS) - - @pytest.fixture() - def colors_disabled(self) -> Colors: - """Create a Colors instance with colors disabled.""" - return Colors(ColorMode.NEVER) - - def test_freeze_error_uses_red(self, colors: Colors) -> None: - """Verify error messages use error color (red).""" - msg = "Session not found" - result = colors.error(msg) - assert "\033[31m" in result # red foreground - assert msg in result - assert result.endswith("\033[0m") # reset at end - - def test_freeze_success_message(self, colors: Colors) -> None: - """Verify success messages use success color (green).""" - result = colors.success("Saved to ") - assert "\033[32m" in result # green foreground - assert "Saved to" in result - - def test_freeze_file_path_uses_info(self, colors: Colors) -> None: - """Verify file paths use info color (cyan).""" - path = "/path/to/config.yaml" - result = colors.info(path) - assert "\033[36m" in result # cyan foreground - assert path in result - - def test_freeze_warning_file_exists(self, colors: Colors) -> None: - """Verify file exists warning uses warning color (yellow).""" - msg = "/path/to/config.yaml exists." - result = colors.warning(msg) - assert "\033[33m" in result # yellow foreground - assert msg in result - - def test_freeze_muted_for_secondary_text(self, colors: Colors) -> None: - """Verify secondary text uses muted color (blue).""" - msg = "Freeze does its best to snapshot live tmux sessions." - result = colors.muted(msg) - assert "\033[34m" in result # blue foreground - assert msg in result - - def test_freeze_colors_disabled_plain_text(self, colors_disabled: Colors) -> None: - """Verify disabled colors return plain text.""" - assert colors_disabled.error("error") == "error" - assert colors_disabled.success("success") == "success" - assert colors_disabled.warning("warning") == "warning" - assert colors_disabled.info("info") == "info" - assert colors_disabled.muted("muted") == "muted" - - def test_freeze_combined_output_format(self, colors: Colors) -> None: - """Verify combined success + info format for 'Saved to ' message.""" - dest = "/home/user/.tmuxp/session.yaml" - output = colors.success("Saved to ") + colors.info(dest) + "." - # Should contain both green and cyan ANSI codes - assert "\033[32m" in output # green for "Saved to" - assert "\033[36m" in output # cyan for path - assert "Saved to" in output - assert dest in output - assert output.endswith(".") - - def test_freeze_warning_with_instructions(self, colors: Colors) -> None: - """Verify warning + muted format for file exists message.""" - path = "/path/to/config.yaml" - output = ( - colors.warning(f"{path} exists.") - + " " - + colors.muted("Pick a new filename.") - ) - # Should contain both yellow and blue ANSI codes - assert "\033[33m" in output # yellow for warning - assert "\033[34m" in output # blue for muted - assert path in output - assert "Pick a new filename." in output - - def test_freeze_url_highlighted_in_help(self, colors: Colors) -> None: - """Verify URLs use info color in help text.""" - url = "" - help_text = colors.muted("tmuxp has examples at ") + colors.info(url) - assert "\033[34m" in help_text # blue for muted text - assert "\033[36m" in help_text # cyan for URL - assert url in help_text + +def test_freeze_error_uses_red(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify error messages use error color (red).""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + msg = "Session not found" + result = colors.error(msg) + assert "\033[31m" in result # red foreground + assert msg in result + assert result.endswith("\033[0m") # reset at end + + +def test_freeze_success_message(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify success messages use success color (green).""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + result = colors.success("Saved to ") + assert "\033[32m" in result # green foreground + assert "Saved to" in result + + +def test_freeze_file_path_uses_info(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify file paths use info color (cyan).""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + path = "/path/to/config.yaml" + result = colors.info(path) + assert "\033[36m" in result # cyan foreground + assert path in result + + +def test_freeze_warning_file_exists(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify file exists warning uses warning color (yellow).""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + msg = "/path/to/config.yaml exists." + result = colors.warning(msg) + assert "\033[33m" in result # yellow foreground + assert msg in result + + +def test_freeze_muted_for_secondary_text(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify secondary text uses muted color (blue).""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + msg = "Freeze does its best to snapshot live tmux sessions." + result = colors.muted(msg) + assert "\033[34m" in result # blue foreground + assert msg in result + + +def test_freeze_colors_disabled_plain_text() -> None: + """Verify disabled colors return plain text.""" + colors = Colors(ColorMode.NEVER) + + assert colors.error("error") == "error" + assert colors.success("success") == "success" + assert colors.warning("warning") == "warning" + assert colors.info("info") == "info" + assert colors.muted("muted") == "muted" + + +def test_freeze_combined_output_format(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify combined success + info format for 'Saved to ' message.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + dest = "/home/user/.tmuxp/session.yaml" + output = colors.success("Saved to ") + colors.info(dest) + "." + # Should contain both green and cyan ANSI codes + assert "\033[32m" in output # green for "Saved to" + assert "\033[36m" in output # cyan for path + assert "Saved to" in output + assert dest in output + assert output.endswith(".") + + +def test_freeze_warning_with_instructions(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify warning + muted format for file exists message.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + path = "/path/to/config.yaml" + output = ( + colors.warning(f"{path} exists.") + " " + colors.muted("Pick a new filename.") + ) + # Should contain both yellow and blue ANSI codes + assert "\033[33m" in output # yellow for warning + assert "\033[34m" in output # blue for muted + assert path in output + assert "Pick a new filename." in output + + +def test_freeze_url_highlighted_in_help(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify URLs use info color in help text.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + url = "" + help_text = colors.muted("tmuxp has examples at ") + colors.info(url) + assert "\033[34m" in help_text # blue for muted text + assert "\033[36m" in help_text # cyan for URL + assert url in help_text diff --git a/tests/cli/test_import_colors.py b/tests/cli/test_import_colors.py index 7e399e7bd1..b8c2b98c38 100644 --- a/tests/cli/test_import_colors.py +++ b/tests/cli/test_import_colors.py @@ -6,97 +6,119 @@ from tmuxp.cli._colors import ColorMode, Colors +# Import command color output tests -class TestImportColorOutput: - """Tests for import command color output formatting.""" - - @pytest.fixture() - def colors(self, monkeypatch: pytest.MonkeyPatch) -> Colors: - """Create a Colors instance with colors enabled.""" - monkeypatch.delenv("NO_COLOR", raising=False) - return Colors(ColorMode.ALWAYS) - - @pytest.fixture() - def colors_disabled(self) -> Colors: - """Create a Colors instance with colors disabled.""" - return Colors(ColorMode.NEVER) - - def test_import_error_unknown_format(self, colors: Colors) -> None: - """Verify unknown format error uses error color (red).""" - msg = "Unknown config format." - result = colors.error(msg) - assert "\033[31m" in result # red foreground - assert msg in result - assert result.endswith("\033[0m") # reset at end - - def test_import_success_message(self, colors: Colors) -> None: - """Verify success messages use success color (green).""" - result = colors.success("Saved to ") - assert "\033[32m" in result # green foreground - assert "Saved to" in result - - def test_import_file_path_uses_info(self, colors: Colors) -> None: - """Verify file paths use info color (cyan).""" - path = "/path/to/config.yaml" - result = colors.info(path) - assert "\033[36m" in result # cyan foreground - assert path in result - - def test_import_muted_for_banner(self, colors: Colors) -> None: - """Verify banner text uses muted color (blue).""" - msg = "Configuration import does its best to convert files." - result = colors.muted(msg) - assert "\033[34m" in result # blue foreground - assert msg in result - - def test_import_muted_for_separator(self, colors: Colors) -> None: - """Verify separator uses muted color (blue).""" - separator = "---------------------------------------------------------------" - result = colors.muted(separator) - assert "\033[34m" in result # blue foreground - assert separator in result - - def test_import_colors_disabled_plain_text(self, colors_disabled: Colors) -> None: - """Verify disabled colors return plain text.""" - assert colors_disabled.error("error") == "error" - assert colors_disabled.success("success") == "success" - assert colors_disabled.muted("muted") == "muted" - assert colors_disabled.info("info") == "info" - - def test_import_combined_success_format(self, colors: Colors) -> None: - """Verify combined success + info format for 'Saved to ' message.""" - dest = "/home/user/.tmuxp/session.yaml" - output = colors.success("Saved to ") + colors.info(dest) + "." - # Should contain both green and cyan ANSI codes - assert "\033[32m" in output # green for "Saved to" - assert "\033[36m" in output # cyan for path - assert "Saved to" in output - assert dest in output - assert output.endswith(".") - - def test_import_help_text_with_urls(self, colors: Colors) -> None: - """Verify help text uses muted for text and info for URLs.""" - url = "" - help_text = colors.muted( - "tmuxp has examples in JSON and YAML format at " - ) + colors.info(url) - assert "\033[34m" in help_text # blue for muted text - assert "\033[36m" in help_text # cyan for URL - assert url in help_text - - def test_import_banner_with_separator(self, colors: Colors) -> None: - """Verify banner format with separator and instruction text.""" - config_content = "session_name: test\n" - separator = "---------------------------------------------------------------" - output = ( - config_content - + colors.muted(separator) - + "\n" - + colors.muted("Configuration import does its best to convert files.") - + "\n" - ) - # Should contain blue ANSI code for muted sections - assert "\033[34m" in output - assert separator in output - assert "Configuration import" in output - assert config_content in output + +def test_import_error_unknown_format(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify unknown format error uses error color (red).""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + msg = "Unknown config format." + result = colors.error(msg) + assert "\033[31m" in result # red foreground + assert msg in result + assert result.endswith("\033[0m") # reset at end + + +def test_import_success_message(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify success messages use success color (green).""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + result = colors.success("Saved to ") + assert "\033[32m" in result # green foreground + assert "Saved to" in result + + +def test_import_file_path_uses_info(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify file paths use info color (cyan).""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + path = "/path/to/config.yaml" + result = colors.info(path) + assert "\033[36m" in result # cyan foreground + assert path in result + + +def test_import_muted_for_banner(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify banner text uses muted color (blue).""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + msg = "Configuration import does its best to convert files." + result = colors.muted(msg) + assert "\033[34m" in result # blue foreground + assert msg in result + + +def test_import_muted_for_separator(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify separator uses muted color (blue).""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + separator = "---------------------------------------------------------------" + result = colors.muted(separator) + assert "\033[34m" in result # blue foreground + assert separator in result + + +def test_import_colors_disabled_plain_text() -> None: + """Verify disabled colors return plain text.""" + colors = Colors(ColorMode.NEVER) + + assert colors.error("error") == "error" + assert colors.success("success") == "success" + assert colors.muted("muted") == "muted" + assert colors.info("info") == "info" + + +def test_import_combined_success_format(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify combined success + info format for 'Saved to ' message.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + dest = "/home/user/.tmuxp/session.yaml" + output = colors.success("Saved to ") + colors.info(dest) + "." + # Should contain both green and cyan ANSI codes + assert "\033[32m" in output # green for "Saved to" + assert "\033[36m" in output # cyan for path + assert "Saved to" in output + assert dest in output + assert output.endswith(".") + + +def test_import_help_text_with_urls(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify help text uses muted for text and info for URLs.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + url = "" + help_text = colors.muted( + "tmuxp has examples in JSON and YAML format at " + ) + colors.info(url) + assert "\033[34m" in help_text # blue for muted text + assert "\033[36m" in help_text # cyan for URL + assert url in help_text + + +def test_import_banner_with_separator(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify banner format with separator and instruction text.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + config_content = "session_name: test\n" + separator = "---------------------------------------------------------------" + output = ( + config_content + + colors.muted(separator) + + "\n" + + colors.muted("Configuration import does its best to convert files.") + + "\n" + ) + # Should contain blue ANSI code for muted sections + assert "\033[34m" in output + assert separator in output + assert "Configuration import" in output + assert config_content in output diff --git a/tests/cli/test_shell_colors.py b/tests/cli/test_shell_colors.py index a9671e4d22..21296b0a86 100644 --- a/tests/cli/test_shell_colors.py +++ b/tests/cli/test_shell_colors.py @@ -6,97 +6,111 @@ from tmuxp.cli._colors import ColorMode, Colors +# Shell command color output tests -class TestShellColorOutput: - """Tests for shell command color output formatting.""" - - @pytest.fixture() - def colors(self, monkeypatch: pytest.MonkeyPatch) -> Colors: - """Create a Colors instance with colors enabled.""" - monkeypatch.delenv("NO_COLOR", raising=False) - return Colors(ColorMode.ALWAYS) - - @pytest.fixture() - def colors_disabled(self) -> Colors: - """Create a Colors instance with colors disabled.""" - return Colors(ColorMode.NEVER) - - def test_shell_launch_message_format(self, colors: Colors) -> None: - """Verify launch message format with shell type and session.""" - shell_name = "ipython" - session_name = "my-session" - output = ( - colors.muted("Launching ") - + colors.highlight(shell_name, bold=False) - + colors.muted(" shell for session ") - + colors.info(session_name) - + colors.muted("...") - ) - # Should contain blue, magenta, and cyan ANSI codes - assert "\033[34m" in output # blue for muted - assert "\033[35m" in output # magenta for highlight - assert "\033[36m" in output # cyan for session name - assert shell_name in output - assert session_name in output - - def test_shell_pdb_launch_message(self, colors: Colors) -> None: - """Verify pdb launch message format.""" - output = ( - colors.muted("Launching ") - + colors.highlight("pdb", bold=False) - + colors.muted(" shell...") - ) - assert "\033[34m" in output # blue for muted - assert "\033[35m" in output # magenta for pdb - assert "pdb" in output - - def test_shell_highlight_not_bold(self, colors: Colors) -> None: - """Verify shell name uses highlight without bold for subtlety.""" - result = colors.highlight("best", bold=False) - assert "\033[35m" in result # magenta foreground - assert "\033[1m" not in result # no bold - subtle emphasis - assert "best" in result - - def test_shell_session_name_uses_info(self, colors: Colors) -> None: - """Verify session name uses info color (cyan).""" - session_name = "dev-session" - result = colors.info(session_name) - assert "\033[36m" in result # cyan foreground - assert session_name in result - - def test_shell_muted_for_static_text(self, colors: Colors) -> None: - """Verify static text uses muted color (blue).""" - result = colors.muted("Launching ") - assert "\033[34m" in result # blue foreground - assert "Launching" in result - - def test_shell_colors_disabled_plain_text(self, colors_disabled: Colors) -> None: - """Verify disabled colors return plain text.""" - shell_name = "ipython" - session_name = "my-session" - output = ( - colors_disabled.muted("Launching ") - + colors_disabled.highlight(shell_name, bold=False) - + colors_disabled.muted(" shell for session ") - + colors_disabled.info(session_name) - + colors_disabled.muted("...") - ) - # Should be plain text without ANSI codes - assert "\033[" not in output - assert output == f"Launching {shell_name} shell for session {session_name}..." - - def test_shell_various_shell_names(self, colors: Colors) -> None: - """Verify all shell types can be highlighted.""" - shell_types = [ - "best", - "pdb", - "code", - "ptipython", - "ptpython", - "ipython", - "bpython", - ] - for shell_name in shell_types: - result = colors.highlight(shell_name, bold=False) - assert "\033[35m" in result - assert shell_name in result + +def test_shell_launch_message_format(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify launch message format with shell type and session.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + shell_name = "ipython" + session_name = "my-session" + output = ( + colors.muted("Launching ") + + colors.highlight(shell_name, bold=False) + + colors.muted(" shell for session ") + + colors.info(session_name) + + colors.muted("...") + ) + # Should contain blue, magenta, and cyan ANSI codes + assert "\033[34m" in output # blue for muted + assert "\033[35m" in output # magenta for highlight + assert "\033[36m" in output # cyan for session name + assert shell_name in output + assert session_name in output + + +def test_shell_pdb_launch_message(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify pdb launch message format.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + output = ( + colors.muted("Launching ") + + colors.highlight("pdb", bold=False) + + colors.muted(" shell...") + ) + assert "\033[34m" in output # blue for muted + assert "\033[35m" in output # magenta for pdb + assert "pdb" in output + + +def test_shell_highlight_not_bold(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify shell name uses highlight without bold for subtlety.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + result = colors.highlight("best", bold=False) + assert "\033[35m" in result # magenta foreground + assert "\033[1m" not in result # no bold - subtle emphasis + assert "best" in result + + +def test_shell_session_name_uses_info(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify session name uses info color (cyan).""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + session_name = "dev-session" + result = colors.info(session_name) + assert "\033[36m" in result # cyan foreground + assert session_name in result + + +def test_shell_muted_for_static_text(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify static text uses muted color (blue).""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + result = colors.muted("Launching ") + assert "\033[34m" in result # blue foreground + assert "Launching" in result + + +def test_shell_colors_disabled_plain_text() -> None: + """Verify disabled colors return plain text.""" + colors = Colors(ColorMode.NEVER) + + shell_name = "ipython" + session_name = "my-session" + output = ( + colors.muted("Launching ") + + colors.highlight(shell_name, bold=False) + + colors.muted(" shell for session ") + + colors.info(session_name) + + colors.muted("...") + ) + # Should be plain text without ANSI codes + assert "\033[" not in output + assert output == f"Launching {shell_name} shell for session {session_name}..." + + +def test_shell_various_shell_names(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify all shell types can be highlighted.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + shell_types = [ + "best", + "pdb", + "code", + "ptipython", + "ptpython", + "ipython", + "bpython", + ] + for shell_name in shell_types: + result = colors.highlight(shell_name, bold=False) + assert "\033[35m" in result + assert shell_name in result From bc530d15dbecf53bd7d6aa52a7c16ed2f7c23949 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 3 Jan 2026 12:01:34 -0600 Subject: [PATCH 11/99] feat(_internal): Add PrivatePath for privacy-masking paths Add PrivatePath class that collapses home directory to ~ in string output, preventing PII exposure in logs and debug output. - PrivatePath: pathlib.Path subclass with masked __str__/__repr__ - collapse_home_in_string(): Helper for PATH-like colon-separated strings - Comprehensive tests for both utilities --- src/tmuxp/_internal/private_path.py | 140 +++++++++++++++++++++++++++ tests/_internal/__init__.py | 1 + tests/_internal/test_private_path.py | 124 ++++++++++++++++++++++++ 3 files changed, 265 insertions(+) create mode 100644 src/tmuxp/_internal/private_path.py create mode 100644 tests/_internal/__init__.py create mode 100644 tests/_internal/test_private_path.py diff --git a/src/tmuxp/_internal/private_path.py b/src/tmuxp/_internal/private_path.py new file mode 100644 index 0000000000..2ab8a998ae --- /dev/null +++ b/src/tmuxp/_internal/private_path.py @@ -0,0 +1,140 @@ +"""Privacy-aware path utilities for hiding sensitive directory information. + +This module provides utilities for masking user home directories in path output, +useful for logging, debugging, and displaying paths without exposing PII. +""" + +from __future__ import annotations + +import os +import pathlib +import typing as t + +if t.TYPE_CHECKING: + PrivatePathBase = pathlib.Path +else: + PrivatePathBase = type(pathlib.Path()) + + +class PrivatePath(PrivatePathBase): + """Path subclass that hides the user's home directory in textual output. + + The class behaves like :class:`pathlib.Path`, but normalizes string and + representation output to replace the current user's home directory with + ``~``. This is useful when logging or displaying paths that should not leak + potentially sensitive information. + + Examples + -------- + >>> from pathlib import Path + >>> home = Path.home() + + >>> PrivatePath(home) + PrivatePath('~') + + >>> PrivatePath(home / "projects" / "tmuxp") + PrivatePath('~/projects/tmuxp') + + >>> str(PrivatePath("/tmp/example")) + '/tmp/example' + + >>> f'config: {PrivatePath(home / ".tmuxp" / "config.yaml")}' # doctest: +ELLIPSIS + 'config: ~/.tmuxp/config.yaml' + """ + + def __new__(cls, *args: t.Any, **kwargs: t.Any) -> PrivatePath: + """Create a new PrivatePath instance.""" + return super().__new__(cls, *args, **kwargs) + + @classmethod + def _collapse_home(cls, value: str) -> str: + """Collapse the user's home directory to ``~`` in ``value``. + + Parameters + ---------- + value : str + Path string to process + + Returns + ------- + str + Path with home directory replaced by ``~`` if applicable + + Examples + -------- + >>> import pathlib + >>> home = str(pathlib.Path.home()) + >>> PrivatePath._collapse_home(home) + '~' + >>> PrivatePath._collapse_home(home + "/projects") + '~/projects' + >>> PrivatePath._collapse_home("/tmp/test") + '/tmp/test' + >>> PrivatePath._collapse_home("~/already/collapsed") + '~/already/collapsed' + """ + if value.startswith("~"): + return value + + home = str(pathlib.Path.home()) + if value == home: + return "~" + + separators = {os.sep} + if os.altsep: + separators.add(os.altsep) + + for sep in separators: + home_with_sep = home + sep + if value.startswith(home_with_sep): + return "~" + value[len(home) :] + + return value + + def __str__(self) -> str: + """Return string representation with home directory collapsed to ~.""" + original = pathlib.Path.__str__(self) + return self._collapse_home(original) + + def __repr__(self) -> str: + """Return repr with home directory collapsed to ~.""" + return f"{self.__class__.__name__}({str(self)!r})" + + +def collapse_home_in_string(text: str) -> str: + """Collapse home directory paths within a colon-separated string. + + Useful for processing PATH-like environment variables that may contain + multiple paths, some of which are under the user's home directory. + + Parameters + ---------- + text : str + String potentially containing paths separated by colons (or semicolons + on Windows) + + Returns + ------- + str + String with home directory paths collapsed to ``~`` + + Examples + -------- + >>> import pathlib + >>> home = str(pathlib.Path.home()) + >>> collapse_home_in_string(f"{home}/.local/bin:/usr/bin") # doctest: +ELLIPSIS + '~/.local/bin:/usr/bin' + >>> collapse_home_in_string("/usr/bin:/bin") + '/usr/bin:/bin' + >>> path_str = f"{home}/bin:{home}/.cargo/bin:/usr/bin" + >>> collapse_home_in_string(path_str) # doctest: +ELLIPSIS + '~/bin:~/.cargo/bin:/usr/bin' + """ + # Handle both Unix (:) and Windows (;) path separators + separator = ";" if os.name == "nt" else ":" + parts = text.split(separator) + collapsed = [PrivatePath._collapse_home(part) for part in parts] + return separator.join(collapsed) + + +__all__ = ["PrivatePath", "collapse_home_in_string"] diff --git a/tests/_internal/__init__.py b/tests/_internal/__init__.py new file mode 100644 index 0000000000..10efabce8c --- /dev/null +++ b/tests/_internal/__init__.py @@ -0,0 +1 @@ +"""Tests for tmuxp internal modules.""" diff --git a/tests/_internal/test_private_path.py b/tests/_internal/test_private_path.py new file mode 100644 index 0000000000..7e9f3f4979 --- /dev/null +++ b/tests/_internal/test_private_path.py @@ -0,0 +1,124 @@ +"""Tests for PrivatePath privacy-masking utilities.""" + +from __future__ import annotations + +import pathlib + +import pytest + +from tmuxp._internal.private_path import PrivatePath, collapse_home_in_string + +# PrivatePath tests + + +def test_private_path_collapses_home(monkeypatch: pytest.MonkeyPatch) -> None: + """PrivatePath replaces home directory with ~.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + path = PrivatePath("/home/testuser/projects/tmuxp") + assert str(path) == "~/projects/tmuxp" + + +def test_private_path_collapses_home_exact(monkeypatch: pytest.MonkeyPatch) -> None: + """PrivatePath handles exact home directory match.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + path = PrivatePath("/home/testuser") + assert str(path) == "~" + + +def test_private_path_preserves_non_home(monkeypatch: pytest.MonkeyPatch) -> None: + """PrivatePath preserves paths outside home directory.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + path = PrivatePath("/usr/bin/tmux") + assert str(path) == "/usr/bin/tmux" + + +def test_private_path_preserves_tmp(monkeypatch: pytest.MonkeyPatch) -> None: + """PrivatePath preserves /tmp paths.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + path = PrivatePath("/tmp/example") + assert str(path) == "/tmp/example" + + +def test_private_path_preserves_already_collapsed() -> None: + """PrivatePath preserves paths already starting with ~.""" + path = PrivatePath("~/already/collapsed") + assert str(path) == "~/already/collapsed" + + +def test_private_path_repr(monkeypatch: pytest.MonkeyPatch) -> None: + """PrivatePath repr shows class name and collapsed path.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + path = PrivatePath("/home/testuser/config.yaml") + assert repr(path) == "PrivatePath('~/config.yaml')" + + +def test_private_path_in_fstring(monkeypatch: pytest.MonkeyPatch) -> None: + """PrivatePath works in f-strings with collapsed home.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + path = PrivatePath("/home/testuser/.tmuxp/session.yaml") + result = f"config: {path}" + assert result == "config: ~/.tmuxp/session.yaml" + + +def test_private_path_similar_prefix_not_collapsed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """PrivatePath does not collapse paths with similar prefix but different user.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + # /home/testuser2 should NOT be collapsed even though it starts with /home/testuser + path = PrivatePath("/home/testuser2/projects") + assert str(path) == "/home/testuser2/projects" + + +# collapse_home_in_string tests + + +def test_collapse_home_in_string_single_path(monkeypatch: pytest.MonkeyPatch) -> None: + """collapse_home_in_string handles a single path.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + result = collapse_home_in_string("/home/testuser/.local/bin") + assert result == "~/.local/bin" + + +def test_collapse_home_in_string_multiple_paths( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """collapse_home_in_string handles colon-separated paths.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + result = collapse_home_in_string( + "/home/testuser/bin:/home/testuser/.cargo/bin:/usr/bin" + ) + assert result == "~/bin:~/.cargo/bin:/usr/bin" + + +def test_collapse_home_in_string_no_home_paths( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """collapse_home_in_string preserves paths not under home.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + result = collapse_home_in_string("/usr/bin:/bin:/usr/local/bin") + assert result == "/usr/bin:/bin:/usr/local/bin" + + +def test_collapse_home_in_string_mixed_paths(monkeypatch: pytest.MonkeyPatch) -> None: + """collapse_home_in_string handles mixed home and non-home paths.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + result = collapse_home_in_string("/usr/bin:/home/testuser/.local/bin:/bin") + assert result == "/usr/bin:~/.local/bin:/bin" + + +def test_collapse_home_in_string_empty() -> None: + """collapse_home_in_string handles empty string.""" + result = collapse_home_in_string("") + assert result == "" From 8e6ed96419acd155522a5ced503cd22f1de4cb56 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 3 Jan 2026 12:03:59 -0600 Subject: [PATCH 12/99] feat(cli[_colors]): Add formatting helpers for structured output Add reusable formatting methods to Colors class for syntax highlighting in structured output like debug-info: - format_label(): Bold magenta for key/label text - format_path(): Cyan for file paths - format_version(): Cyan for version strings - format_separator(): Muted separator lines - format_kv(): Key: value pairs with highlighted key - format_tmux_option(): Handles "key value" and "key=value" formats --- src/tmuxp/cli/_colors.py | 148 +++++++++++++++++++++ tests/cli/test_colors_formatters.py | 195 ++++++++++++++++++++++++++++ 2 files changed, 343 insertions(+) create mode 100644 tests/cli/test_colors_formatters.py diff --git a/src/tmuxp/cli/_colors.py b/src/tmuxp/cli/_colors.py index 7a9a087f88..a6fc946791 100644 --- a/src/tmuxp/cli/_colors.py +++ b/src/tmuxp/cli/_colors.py @@ -318,6 +318,154 @@ def muted(self, text: str) -> str: """ return self._colorize(text, self.MUTED, bold=False) + # Formatting helpers for structured output + + def format_label(self, label: str) -> str: + """Format a label (key in key:value pair). + + Parameters + ---------- + label : str + Label text to format. + + Returns + ------- + str + Highlighted label text (bold magenta when colors enabled). + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.format_label("tmux path") + 'tmux path' + """ + return self.highlight(label, bold=True) + + def format_path(self, path: str) -> str: + """Format a file path with info color. + + Parameters + ---------- + path : str + Path string to format. + + Returns + ------- + str + Cyan-colored path when colors enabled. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.format_path("/usr/bin/tmux") + '/usr/bin/tmux' + """ + return self.info(path) + + def format_version(self, version: str) -> str: + """Format a version string. + + Parameters + ---------- + version : str + Version string to format. + + Returns + ------- + str + Cyan-colored version when colors enabled. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.format_version("3.2a") + '3.2a' + """ + return self.info(version) + + def format_separator(self, length: int = 25) -> str: + """Format a visual separator line. + + Parameters + ---------- + length : int + Length of the separator line. Default is 25. + + Returns + ------- + str + Muted (blue) separator line when colors enabled. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.format_separator() + '-------------------------' + >>> colors.format_separator(10) + '----------' + """ + return self.muted("-" * length) + + def format_kv(self, key: str, value: str) -> str: + """Format key: value pair with syntax highlighting. + + Parameters + ---------- + key : str + Key/label to highlight. + value : str + Value to display (not colorized, allows caller to format). + + Returns + ------- + str + Formatted "key: value" string with highlighted key. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.format_kv("tmux version", "3.2a") + 'tmux version: 3.2a' + """ + return f"{self.format_label(key)}: {value}" + + def format_tmux_option(self, line: str) -> str: + """Format tmux option line with syntax highlighting. + + Handles both "key=value" and "key value" formats commonly + returned by tmux show-options commands. + + Parameters + ---------- + line : str + Option line to format. + + Returns + ------- + str + Formatted line with highlighted key and info-colored value. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.format_tmux_option("status on") + 'status on' + >>> colors.format_tmux_option("base-index=1") + 'base-index=1' + """ + # Handle key=value format + if "=" in line: + key, val = line.split("=", 1) + return f"{self.highlight(key, bold=False)}={self.info(val)}" + + # Handle "key value" format (space-separated) + parts = line.split(None, 1) + if len(parts) == 2: + return f"{self.highlight(parts[0], bold=False)} {self.info(parts[1])}" + + # Single value or unparseable - return as-is + return line + def get_color_mode(color_arg: str | None = None) -> ColorMode: """Convert CLI argument string to ColorMode enum. diff --git a/tests/cli/test_colors_formatters.py b/tests/cli/test_colors_formatters.py new file mode 100644 index 0000000000..87c96b8d94 --- /dev/null +++ b/tests/cli/test_colors_formatters.py @@ -0,0 +1,195 @@ +"""Tests for Colors class formatting helper methods.""" + +from __future__ import annotations + +import pytest + +from tmuxp.cli._colors import ColorMode, Colors + +# format_label tests + + +def test_format_label_plain_text() -> None: + """format_label returns plain text when colors disabled.""" + colors = Colors(ColorMode.NEVER) + assert colors.format_label("tmux path") == "tmux path" + + +def test_format_label_applies_highlight(monkeypatch: pytest.MonkeyPatch) -> None: + """format_label applies highlight (bold magenta) when enabled.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + result = colors.format_label("tmux path") + assert "\033[35m" in result # magenta + assert "\033[1m" in result # bold + assert "tmux path" in result + + +# format_path tests + + +def test_format_path_plain_text() -> None: + """format_path returns plain text when colors disabled.""" + colors = Colors(ColorMode.NEVER) + assert colors.format_path("/usr/bin/tmux") == "/usr/bin/tmux" + + +def test_format_path_applies_info(monkeypatch: pytest.MonkeyPatch) -> None: + """format_path applies info color (cyan) when enabled.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + result = colors.format_path("/usr/bin/tmux") + assert "\033[36m" in result # cyan + assert "/usr/bin/tmux" in result + + +# format_version tests + + +def test_format_version_plain_text() -> None: + """format_version returns plain text when colors disabled.""" + colors = Colors(ColorMode.NEVER) + assert colors.format_version("3.2a") == "3.2a" + + +def test_format_version_applies_info(monkeypatch: pytest.MonkeyPatch) -> None: + """format_version applies info color (cyan) when enabled.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + result = colors.format_version("3.2a") + assert "\033[36m" in result # cyan + assert "3.2a" in result + + +# format_separator tests + + +def test_format_separator_default_length() -> None: + """format_separator creates 25-character separator by default.""" + colors = Colors(ColorMode.NEVER) + result = colors.format_separator() + assert result == "-" * 25 + + +def test_format_separator_custom_length() -> None: + """format_separator respects custom length.""" + colors = Colors(ColorMode.NEVER) + assert colors.format_separator(10) == "-" * 10 + assert colors.format_separator(50) == "-" * 50 + + +def test_format_separator_applies_muted(monkeypatch: pytest.MonkeyPatch) -> None: + """format_separator applies muted color (blue) when enabled.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + result = colors.format_separator() + assert "\033[34m" in result # blue + assert "-" * 25 in result + + +# format_kv tests + + +def test_format_kv_plain_text() -> None: + """format_kv returns plain key: value when colors disabled.""" + colors = Colors(ColorMode.NEVER) + assert colors.format_kv("tmux version", "3.2a") == "tmux version: 3.2a" + + +def test_format_kv_highlights_key(monkeypatch: pytest.MonkeyPatch) -> None: + """format_kv highlights the key but not the value.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + result = colors.format_kv("tmux version", "3.2a") + assert "\033[35m" in result # magenta for key + assert "\033[1m" in result # bold for key + assert "tmux version" in result + assert ": 3.2a" in result + + +def test_format_kv_empty_value() -> None: + """format_kv handles empty value.""" + colors = Colors(ColorMode.NEVER) + assert colors.format_kv("environment", "") == "environment: " + + +# format_tmux_option tests + + +def test_format_tmux_option_plain_text_key_value() -> None: + """format_tmux_option returns plain text when colors disabled (key=value).""" + colors = Colors(ColorMode.NEVER) + assert colors.format_tmux_option("base-index=1") == "base-index=1" + + +def test_format_tmux_option_plain_text_space_sep() -> None: + """format_tmux_option returns plain text when colors disabled (space-sep).""" + colors = Colors(ColorMode.NEVER) + assert colors.format_tmux_option("status on") == "status on" + + +def test_format_tmux_option_key_value_format(monkeypatch: pytest.MonkeyPatch) -> None: + """format_tmux_option highlights key=value format.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + result = colors.format_tmux_option("base-index=1") + assert "\033[35m" in result # magenta for key + assert "\033[36m" in result # cyan for value + assert "base-index" in result + assert "=1" in result or "1" in result + + +def test_format_tmux_option_space_separated(monkeypatch: pytest.MonkeyPatch) -> None: + """format_tmux_option highlights space-separated format.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + result = colors.format_tmux_option("status on") + assert "\033[35m" in result # magenta for key + assert "\033[36m" in result # cyan for value + assert "status" in result + assert "on" in result + + +def test_format_tmux_option_single_word() -> None: + """format_tmux_option returns single words unchanged.""" + colors = Colors(ColorMode.NEVER) + assert colors.format_tmux_option("default") == "default" + + +def test_format_tmux_option_empty() -> None: + """format_tmux_option handles empty string.""" + colors = Colors(ColorMode.NEVER) + assert colors.format_tmux_option("") == "" + + +def test_format_tmux_option_value_with_spaces( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """format_tmux_option handles values containing spaces.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + # tmux options can have values with spaces like status-left "string here" + result = colors.format_tmux_option('status-left "#S: #W"') + assert "status-left" in result + assert '"#S: #W"' in result + + +def test_format_tmux_option_value_with_equals( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """format_tmux_option handles values containing equals signs.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + # Only split on first equals + result = colors.format_tmux_option("command=echo a=b") + assert "command" in result + assert "echo a=b" in result From 60fde8d0edf91ee6d3c31e09f15443516e02bb62 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 3 Jan 2026 12:06:27 -0600 Subject: [PATCH 13/99] feat(cli[debug-info]): Add syntax highlighting and privacy masking Apply semantic colors and privacy masking to debug-info output: - Labels highlighted in bold magenta - Paths and versions in cyan - Separators in muted blue - tmux options with key=value highlighting - Home directory paths collapsed to ~ for privacy - System PATH env var also privacy-masked --- src/tmuxp/cli/debug_info.py | 99 ++++++++------- tests/cli/test_debug_info_colors.py | 187 ++++++++++++++++++++++++++++ 2 files changed, 242 insertions(+), 44 deletions(-) create mode 100644 tests/cli/test_debug_info_colors.py diff --git a/src/tmuxp/cli/debug_info.py b/src/tmuxp/cli/debug_info.py index 9245c7df47..a0261dd1a9 100644 --- a/src/tmuxp/cli/debug_info.py +++ b/src/tmuxp/cli/debug_info.py @@ -14,6 +14,7 @@ from libtmux.common import get_version, tmux_cmd from tmuxp.__about__ import __version__ +from tmuxp._internal.private_path import PrivatePath, collapse_home_in_string from ._colors import Colors, get_color_mode from .utils import tmuxp_echo @@ -48,57 +49,67 @@ def command_debug_info( color_mode = get_color_mode(args.color if args else None) colors = Colors(color_mode) - def prepend_tab(strings: list[str]) -> list[str]: - """Prepend tab to strings in list.""" - return [f"\t{x}" for x in strings] - - def output_break() -> str: - """Generate output break.""" - return "-" * 25 + def private(path: pathlib.Path | str | None) -> str: + """Privacy-mask a path by collapsing home directory to ~.""" + if path is None: + return "" + return str(PrivatePath(path)) def format_tmux_resp(std_resp: tmux_cmd) -> str: - """Format tmux command response for tmuxp stdout.""" - stderr_lines = "\n".join(prepend_tab(std_resp.stderr)) - return "\n".join( - [ - "\n".join(prepend_tab(std_resp.stdout)), - colors.error(stderr_lines) if stderr_lines.strip() else "", - ], - ) + """Format tmux command response with syntax highlighting.""" + stdout_lines = [] + for line in std_resp.stdout: + formatted = colors.format_tmux_option(line) + stdout_lines.append(f"\t{formatted}") + + stderr_formatted = "" + if std_resp.stderr: + stderr_lines = "\n".join(f"\t{line}" for line in std_resp.stderr) + if stderr_lines.strip(): + stderr_formatted = colors.error(stderr_lines) + + return "\n".join(["\n".join(stdout_lines), stderr_formatted]) + + # Build environment section with indented key-value pairs + env_items = [ + f"\t{colors.format_kv('dist', platform.platform())}", + f"\t{colors.format_kv('arch', platform.machine())}", + f"\t{colors.format_kv('uname', '; '.join(platform.uname()[:3]))}", + f"\t{colors.format_kv('version', platform.version())}", + ] output = [ - output_break(), - "environment:\n{}".format( - "\n".join( - prepend_tab( - [ - f"dist: {platform.platform()}", - f"arch: {platform.machine()}", - "uname: {}".format("; ".join(platform.uname()[:3])), - f"version: {platform.version()}", - ], - ), - ), + colors.format_separator(), + f"{colors.format_label('environment')}:\n" + "\n".join(env_items), + colors.format_separator(), + colors.format_kv( + "python version", + " ".join(sys.version.split("\n")), ), - output_break(), - "python version: {}".format(" ".join(sys.version.split("\n"))), - "system PATH: {}".format(os.environ["PATH"]), - f"tmux version: {get_version()}", - f"libtmux version: {libtmux_version}", - f"tmuxp version: {__version__}", - "tmux path: {}".format(shutil.which("tmux")), - f"tmuxp path: {tmuxp_path}", - "shell: {}".format(os.environ["SHELL"]), - output_break(), - "tmux sessions:\n{}".format(format_tmux_resp(tmux_cmd("list-sessions"))), - "tmux windows:\n{}".format(format_tmux_resp(tmux_cmd("list-windows"))), - "tmux panes:\n{}".format(format_tmux_resp(tmux_cmd("list-panes"))), - "tmux global options:\n{}".format( - format_tmux_resp(tmux_cmd("show-options", "-g")), + colors.format_kv( + "system PATH", + collapse_home_in_string(os.environ.get("PATH", "")), ), - "tmux window options:\n{}".format( - format_tmux_resp(tmux_cmd("show-window-options", "-g")), + colors.format_kv("tmux version", colors.format_version(str(get_version()))), + colors.format_kv("libtmux version", colors.format_version(libtmux_version)), + colors.format_kv("tmuxp version", colors.format_version(__version__)), + colors.format_kv( + "tmux path", + colors.format_path(private(shutil.which("tmux"))), ), + colors.format_kv("tmuxp path", colors.format_path(private(tmuxp_path))), + colors.format_kv("shell", private(os.environ.get("SHELL", ""))), + colors.format_separator(), + f"{colors.format_label('tmux sessions')}:\n" + + format_tmux_resp(tmux_cmd("list-sessions")), + f"{colors.format_label('tmux windows')}:\n" + + format_tmux_resp(tmux_cmd("list-windows")), + f"{colors.format_label('tmux panes')}:\n" + + format_tmux_resp(tmux_cmd("list-panes")), + f"{colors.format_label('tmux global options')}:\n" + + format_tmux_resp(tmux_cmd("show-options", "-g")), + f"{colors.format_label('tmux window options')}:\n" + + format_tmux_resp(tmux_cmd("show-window-options", "-g")), ] tmuxp_echo("\n".join(output)) diff --git a/tests/cli/test_debug_info_colors.py b/tests/cli/test_debug_info_colors.py new file mode 100644 index 0000000000..2e890a7ea2 --- /dev/null +++ b/tests/cli/test_debug_info_colors.py @@ -0,0 +1,187 @@ +"""Tests for debug-info command color output and privacy masking.""" + +from __future__ import annotations + +import pathlib + +import pytest + +from tmuxp._internal.private_path import PrivatePath, collapse_home_in_string +from tmuxp.cli._colors import ColorMode, Colors + +# Privacy masking in debug-info context + + +def test_debug_info_masks_home_in_paths(monkeypatch: pytest.MonkeyPatch) -> None: + """debug-info should mask home directory in paths.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + # Simulate what debug-info does with tmuxp_path + tmuxp_path = pathlib.Path("/home/testuser/work/python/tmuxp/src/tmuxp") + private_path = str(PrivatePath(tmuxp_path)) + + assert private_path == "~/work/python/tmuxp/src/tmuxp" + assert "/home/testuser" not in private_path + + +def test_debug_info_masks_home_in_system_path( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """debug-info should mask home directory in system PATH.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + path_env = "/home/testuser/.local/bin:/usr/bin:/home/testuser/.cargo/bin" + masked = collapse_home_in_string(path_env) + + assert masked == "~/.local/bin:/usr/bin:~/.cargo/bin" + assert "/home/testuser" not in masked + + +def test_debug_info_preserves_system_paths(monkeypatch: pytest.MonkeyPatch) -> None: + """debug-info should preserve paths outside home directory.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + tmux_path = "/usr/bin/tmux" + private_path = str(PrivatePath(tmux_path)) + + assert private_path == "/usr/bin/tmux" + + +# Formatting helpers in debug-info context + + +def test_debug_info_format_kv_labels(monkeypatch: pytest.MonkeyPatch) -> None: + """debug-info should highlight labels in key-value pairs.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + result = colors.format_kv("tmux version", "3.2a") + assert "\033[35m" in result # magenta for label + assert "\033[1m" in result # bold for label + assert "tmux version" in result + assert "3.2a" in result + + +def test_debug_info_format_version(monkeypatch: pytest.MonkeyPatch) -> None: + """debug-info should highlight version strings.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + result = colors.format_kv("tmux version", colors.format_version("3.2a")) + assert "\033[36m" in result # cyan for version + assert "3.2a" in result + + +def test_debug_info_format_path(monkeypatch: pytest.MonkeyPatch) -> None: + """debug-info should highlight paths.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + result = colors.format_kv("tmux path", colors.format_path("/usr/bin/tmux")) + assert "\033[36m" in result # cyan for path + assert "/usr/bin/tmux" in result + + +def test_debug_info_format_separator(monkeypatch: pytest.MonkeyPatch) -> None: + """debug-info should use muted separators.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + result = colors.format_separator() + assert "\033[34m" in result # blue for muted + assert "-" * 25 in result + + +# tmux option formatting + + +def test_debug_info_format_tmux_option_space_sep( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """debug-info should format space-separated tmux options.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + result = colors.format_tmux_option("status on") + assert "\033[35m" in result # magenta for key + assert "\033[36m" in result # cyan for value + assert "status" in result + assert "on" in result + + +def test_debug_info_format_tmux_option_equals_sep( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """debug-info should format equals-separated tmux options.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + result = colors.format_tmux_option("base-index=0") + assert "\033[35m" in result # magenta for key + assert "\033[36m" in result # cyan for value + assert "base-index" in result + assert "0" in result + + +# Color mode behavior + + +def test_debug_info_respects_never_mode() -> None: + """debug-info should return plain text in NEVER mode.""" + colors = Colors(ColorMode.NEVER) + + result = colors.format_kv("tmux version", colors.format_version("3.2a")) + assert "\033[" not in result + assert result == "tmux version: 3.2a" + + +def test_debug_info_respects_no_color_env(monkeypatch: pytest.MonkeyPatch) -> None: + """debug-info should respect NO_COLOR environment variable.""" + monkeypatch.setenv("NO_COLOR", "1") + colors = Colors(ColorMode.ALWAYS) + + result = colors.format_kv("tmux path", "/usr/bin/tmux") + assert "\033[" not in result + assert result == "tmux path: /usr/bin/tmux" + + +# Combined formatting + + +def test_debug_info_combined_path_with_privacy( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """debug-info should combine privacy masking with color formatting.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + # Simulate what debug-info does + raw_path = "/home/testuser/work/tmuxp/src/tmuxp" + private_path = str(PrivatePath(raw_path)) + formatted = colors.format_kv("tmuxp path", colors.format_path(private_path)) + + assert "~/work/tmuxp/src/tmuxp" in formatted + assert "/home/testuser" not in formatted + assert "\033[36m" in formatted # cyan for path + assert "\033[35m" in formatted # magenta for label + + +def test_debug_info_environment_section_format( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """debug-info environment section should have proper format.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + # Simulate environment section format + env_items = [ + f"\t{colors.format_kv('dist', 'Linux-6.6.87')}", + f"\t{colors.format_kv('arch', 'x86_64')}", + ] + section = f"{colors.format_label('environment')}:\n" + "\n".join(env_items) + + assert "environment" in section + assert "\t" in section # indented items + assert "dist" in section + assert "arch" in section From ca623f3b609b932beef22941fe4d1c6e35a61bbe Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 3 Jan 2026 12:30:19 -0600 Subject: [PATCH 14/99] fix(cli[_colors]): Handle all tmux option formats in format_tmux_option - Prioritize space-separated format to handle values containing '=' (e.g., status-format[0] "#[align=left]") - Add support for empty array options like 'pane-colours' (key only) - Add test coverage for array-indexed options (status-format[0]) - Update docstring to document all supported formats --- src/tmuxp/cli/_colors.py | 28 ++++++++++----- tests/cli/test_colors_formatters.py | 54 +++++++++++++++++++++++++---- 2 files changed, 67 insertions(+), 15 deletions(-) diff --git a/src/tmuxp/cli/_colors.py b/src/tmuxp/cli/_colors.py index a6fc946791..35bd90f85b 100644 --- a/src/tmuxp/cli/_colors.py +++ b/src/tmuxp/cli/_colors.py @@ -432,8 +432,11 @@ def format_kv(self, key: str, value: str) -> str: def format_tmux_option(self, line: str) -> str: """Format tmux option line with syntax highlighting. - Handles both "key=value" and "key value" formats commonly - returned by tmux show-options commands. + Handles tmux show-options output formats: + - "key value" (space-separated) + - "key=value" (equals-separated) + - "key[index] value" (array-indexed options) + - "key" (empty array options with no value) Parameters ---------- @@ -452,18 +455,27 @@ def format_tmux_option(self, line: str) -> str: 'status on' >>> colors.format_tmux_option("base-index=1") 'base-index=1' + >>> colors.format_tmux_option("pane-colours") + 'pane-colours' + >>> colors.format_tmux_option('status-format[0] "#[align=left]"') + 'status-format[0] "#[align=left]"' """ - # Handle key=value format + # Handle "key value" format (space-separated) - check first since values + # may contain '=' (e.g., status-format[0] "#[align=left]") + parts = line.split(None, 1) + if len(parts) == 2: + return f"{self.highlight(parts[0], bold=False)} {self.info(parts[1])}" + + # Handle key=value format (only for single-token lines) if "=" in line: key, val = line.split("=", 1) return f"{self.highlight(key, bold=False)}={self.info(val)}" - # Handle "key value" format (space-separated) - parts = line.split(None, 1) - if len(parts) == 2: - return f"{self.highlight(parts[0], bold=False)} {self.info(parts[1])}" + # Single word = key with no value (empty array option like pane-colours) + if len(parts) == 1 and parts[0]: + return self.highlight(parts[0], bold=False) - # Single value or unparseable - return as-is + # Empty or unparseable - return as-is return line diff --git a/tests/cli/test_colors_formatters.py b/tests/cli/test_colors_formatters.py index 87c96b8d94..86c8587708 100644 --- a/tests/cli/test_colors_formatters.py +++ b/tests/cli/test_colors_formatters.py @@ -158,9 +158,21 @@ def test_format_tmux_option_space_separated(monkeypatch: pytest.MonkeyPatch) -> def test_format_tmux_option_single_word() -> None: - """format_tmux_option returns single words unchanged.""" + """format_tmux_option returns single words (empty array options) unchanged.""" colors = Colors(ColorMode.NEVER) - assert colors.format_tmux_option("default") == "default" + assert colors.format_tmux_option("pane-colours") == "pane-colours" + + +def test_format_tmux_option_single_word_highlighted( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """format_tmux_option highlights single words (empty array options) as keys.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + result = colors.format_tmux_option("pane-colours") + assert "\033[35m" in result # magenta for key + assert "pane-colours" in result def test_format_tmux_option_empty() -> None: @@ -169,6 +181,32 @@ def test_format_tmux_option_empty() -> None: assert colors.format_tmux_option("") == "" +def test_format_tmux_option_array_indexed(monkeypatch: pytest.MonkeyPatch) -> None: + """format_tmux_option handles array-indexed keys like status-format[0].""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + result = colors.format_tmux_option('status-format[0] "#[align=left]"') + assert "\033[35m" in result # magenta for key + assert "\033[36m" in result # cyan for value + assert "status-format[0]" in result + assert "#[align=left]" in result + + +def test_format_tmux_option_array_indexed_complex_value( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """format_tmux_option handles complex format strings as values.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + # Real tmux status-format value (truncated for test) + line = 'status-format[0] "#[align=left range=left #{E:status-left-style}]"' + result = colors.format_tmux_option(line) + assert "status-format[0]" in result + assert "#[align=left" in result + + def test_format_tmux_option_value_with_spaces( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -185,11 +223,13 @@ def test_format_tmux_option_value_with_spaces( def test_format_tmux_option_value_with_equals( monkeypatch: pytest.MonkeyPatch, ) -> None: - """format_tmux_option handles values containing equals signs.""" + """format_tmux_option splits only on first equals for key=value format.""" monkeypatch.delenv("NO_COLOR", raising=False) colors = Colors(ColorMode.ALWAYS) - # Only split on first equals - result = colors.format_tmux_option("command=echo a=b") - assert "command" in result - assert "echo a=b" in result + # Only split on first equals (no spaces = key=value format) + result = colors.format_tmux_option("option=a=b=c") + assert "option" in result + assert "a=b=c" in result + assert "\033[35m" in result # magenta for key + assert "\033[36m" in result # cyan for value From 8fe234b3fe18724bd7c71f35e0a637e9151d7d81 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 3 Jan 2026 12:57:50 -0600 Subject: [PATCH 15/99] docs(cli[_colors]): Use narrative description in doctest Per CLAUDE.md guidelines, use narrative descriptions for test sections rather than inline comments. --- src/tmuxp/cli/_colors.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/tmuxp/cli/_colors.py b/src/tmuxp/cli/_colors.py index 35bd90f85b..1a539ec00f 100644 --- a/src/tmuxp/cli/_colors.py +++ b/src/tmuxp/cli/_colors.py @@ -89,7 +89,10 @@ class Colors: >>> colors = Colors(ColorMode.ALWAYS) >>> result = colors.success("ok") - >>> "\033[" in result # Contains ANSI escape + + Check that result contains ANSI escape codes: + + >>> "\033[" in result True """ From ef8c1db174c87abc8939c5b468a2a41e9ecfb056 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 3 Jan 2026 12:59:35 -0600 Subject: [PATCH 16/99] feat(cli[load]): Add color support to _reattach and _load_detached Apply semantic colors to print statements that were missed during the initial color system implementation. --- src/tmuxp/cli/load.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index 30db4ac749..bb228aedb3 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -116,13 +116,15 @@ def load_plugins( return plugins -def _reattach(builder: WorkspaceBuilder) -> None: +def _reattach(builder: WorkspaceBuilder, colors: Colors | None = None) -> None: """ Reattach session (depending on env being inside tmux already or not). Parameters ---------- builder: :class:`workspace.builder.WorkspaceBuilder` + colors : Colors | None + Optional Colors instance for styled output. Notes ----- @@ -137,7 +139,7 @@ def _reattach(builder: WorkspaceBuilder) -> None: plugin.reattach(builder.session) proc = builder.session.cmd("display-message", "-p", "'#S'") for line in proc.stdout: - print(line) # NOQA: T201 RUF100 + print(colors.info(line) if colors else line) # NOQA: T201 RUF100 if "TMUX" in os.environ: builder.session.switch_client() @@ -169,19 +171,22 @@ def _load_attached(builder: WorkspaceBuilder, detached: bool) -> None: builder.session.attach() -def _load_detached(builder: WorkspaceBuilder) -> None: +def _load_detached(builder: WorkspaceBuilder, colors: Colors | None = None) -> None: """ Load workspace in new session but don't attach. Parameters ---------- builder: :class:`workspace.builder.WorkspaceBuilder` + colors : Colors | None + Optional Colors instance for styled output. """ builder.build() assert builder.session is not None - print("Session created in detached state.") # NOQA: T201 RUF100 + msg = "Session created in detached state." + print(colors.info(msg) if colors else msg) # NOQA: T201 RUF100 def _load_append_windows_to_current_session(builder: WorkspaceBuilder) -> None: @@ -356,12 +361,12 @@ def load_workspace( default=True, ) ): - _reattach(builder) + _reattach(builder, cli_colors) return None try: if detached: - _load_detached(builder) + _load_detached(builder, cli_colors) return _setup_plugins(builder) if append: @@ -390,7 +395,7 @@ def load_workspace( elif choice == "a": _load_append_windows_to_current_session(builder) else: - _load_detached(builder) + _load_detached(builder, cli_colors) else: _load_attached(builder, detached) @@ -412,7 +417,7 @@ def load_workspace( builder.session.kill() tmuxp_echo(cli_colors.muted("Session killed.")) elif choice == "a": - _reattach(builder) + _reattach(builder, cli_colors) else: sys.exit() From 7e9892ad4a7d35c044724bec9ac5bf8c54a332c5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 3 Jan 2026 13:36:07 -0600 Subject: [PATCH 17/99] docs(cli[_colors]): Use narrative descriptions in module docstring Per AGENTS.md guidelines, convert inline comments in doctests to narrative descriptions outside the code blocks. --- src/tmuxp/cli/_colors.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/tmuxp/cli/_colors.py b/src/tmuxp/cli/_colors.py index 1a539ec00f..6bc4dd7d49 100644 --- a/src/tmuxp/cli/_colors.py +++ b/src/tmuxp/cli/_colors.py @@ -6,10 +6,10 @@ Examples -------- -Basic usage with automatic TTY detection: +Basic usage with automatic TTY detection (AUTO mode is the default). +In a TTY, colored text is returned; otherwise plain text: ->>> colors = Colors() # AUTO mode by default ->>> # In a TTY, this returns colored text; otherwise plain text +>>> colors = Colors() Force colors on or off: @@ -21,11 +21,10 @@ >>> colors.success("loaded") 'loaded' -Environment variables (NO_COLOR, FORCE_COLOR) are respected: +Environment variables NO_COLOR and FORCE_COLOR are respected. +NO_COLOR takes highest priority. FORCE_COLOR enables colors even without TTY: >>> import os ->>> # NO_COLOR takes highest priority ->>> # FORCE_COLOR enables colors even without TTY """ from __future__ import annotations From fa8407382acdf707ea1b8abe14b6541072073d5f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 3 Jan 2026 13:39:32 -0600 Subject: [PATCH 18/99] refactor(cli): Move ANSI utilities from utils.py to _colors.py Consolidate all color/styling code in _colors.py to eliminate circular dependency. This fixes doctest compatibility with python -m doctest. Moved: style(), unstyle(), strip_ansi(), _interpret_color(), _ansi_colors, _ansi_reset_all, UnknownStyleColor --- src/tmuxp/cli/_colors.py | 232 ++++++++++++++++++++++++++++++++++++++- src/tmuxp/cli/utils.py | 145 ++++-------------------- 2 files changed, 248 insertions(+), 129 deletions(-) diff --git a/src/tmuxp/cli/_colors.py b/src/tmuxp/cli/_colors.py index 6bc4dd7d49..23d4053159 100644 --- a/src/tmuxp/cli/_colors.py +++ b/src/tmuxp/cli/_colors.py @@ -1,8 +1,8 @@ """Color output utilities for tmuxp CLI. This module provides semantic color utilities following patterns from vcspull -and CPython's _colorize module. It integrates with the existing style() function -in utils.py for ANSI rendering. +and CPython's _colorize module. It includes low-level ANSI styling functions +and high-level semantic color utilities. Examples -------- @@ -31,7 +31,14 @@ import enum import os +import re import sys +import typing as t + +if t.TYPE_CHECKING: + from typing import TypeAlias + + CLIColour: TypeAlias = int | tuple[int, int, int] | str class ColorMode(enum.Enum): @@ -161,7 +168,7 @@ def _should_enable(self) -> bool: return sys.stdout.isatty() def _colorize(self, text: str, fg: str, bold: bool = False) -> str: - """Apply color using existing style() function. + """Apply color using style() function. Parameters ---------- @@ -178,9 +185,6 @@ def _colorize(self, text: str, fg: str, bold: bool = False) -> str: Colorized text if enabled, plain text otherwise. """ if self._enabled: - # Lazy import to avoid circular dependency with utils.py - from .utils import style - return style(text, fg=fg, bold=bold) return text @@ -513,3 +517,219 @@ def get_color_mode(color_arg: str | None = None) -> ColorMode: return ColorMode(color_arg.lower()) except ValueError: return ColorMode.AUTO + + +# ANSI styling utilities (originally from click, via utils.py) + +_ansi_re = re.compile(r"\033\[[;?0-9]*[a-zA-Z]") + + +def strip_ansi(value: str) -> str: + r"""Clear ANSI escape codes from a string value. + + Parameters + ---------- + value : str + String potentially containing ANSI escape codes. + + Returns + ------- + str + String with ANSI codes removed. + + Examples + -------- + >>> strip_ansi("\033[32mgreen\033[0m") + 'green' + >>> strip_ansi("plain text") + 'plain text' + """ + return _ansi_re.sub("", value) + + +_ansi_colors = { + "black": 30, + "red": 31, + "green": 32, + "yellow": 33, + "blue": 34, + "magenta": 35, + "cyan": 36, + "white": 37, + "reset": 39, + "bright_black": 90, + "bright_red": 91, + "bright_green": 92, + "bright_yellow": 93, + "bright_blue": 94, + "bright_magenta": 95, + "bright_cyan": 96, + "bright_white": 97, +} +_ansi_reset_all = "\033[0m" + + +def _interpret_color( + color: int | tuple[int, int, int] | str, + offset: int = 0, +) -> str: + """Convert color specification to ANSI escape code number. + + Parameters + ---------- + color : int | tuple[int, int, int] | str + Color as 256-color index, RGB tuple, or color name. + offset : int + Offset for background colors (10 for bg). + + Returns + ------- + str + ANSI escape code parameters. + """ + if isinstance(color, int): + return f"{38 + offset};5;{color:d}" + + if isinstance(color, (tuple, list)): + r, g, b = color + return f"{38 + offset};2;{r:d};{g:d};{b:d}" + + return str(_ansi_colors[color] + offset) + + +class UnknownStyleColor(Exception): + """Raised when encountering an unknown terminal style color. + + Examples + -------- + >>> try: + ... raise UnknownStyleColor("invalid_color") + ... except UnknownStyleColor as e: + ... "invalid_color" in str(e) + True + """ + + def __init__(self, color: CLIColour, *args: object, **kwargs: object) -> None: + return super().__init__(f"Unknown color {color!r}", *args, **kwargs) + + +def style( + text: t.Any, + fg: CLIColour | None = None, + bg: CLIColour | None = None, + bold: bool | None = None, + dim: bool | None = None, + underline: bool | None = None, + overline: bool | None = None, + italic: bool | None = None, + blink: bool | None = None, + reverse: bool | None = None, + strikethrough: bool | None = None, + reset: bool = True, +) -> str: + r"""Apply ANSI styling to text. + + Credit: click. + + Parameters + ---------- + text : Any + Text to style (will be converted to str). + fg : CLIColour | None + Foreground color (name, 256-index, or RGB tuple). + bg : CLIColour | None + Background color. + bold : bool | None + Apply bold style. + dim : bool | None + Apply dim style. + underline : bool | None + Apply underline style. + overline : bool | None + Apply overline style. + italic : bool | None + Apply italic style. + blink : bool | None + Apply blink style. + reverse : bool | None + Apply reverse video style. + strikethrough : bool | None + Apply strikethrough style. + reset : bool + Append reset code at end. Default True. + + Returns + ------- + str + Styled text with ANSI escape codes. + + Examples + -------- + >>> style("hello", fg="green") # doctest: +ELLIPSIS + '\x1b[32m...' + >>> "hello" in style("hello", fg="green") + True + """ + if not isinstance(text, str): + text = str(text) + + bits = [] + + if fg: + try: + bits.append(f"\033[{_interpret_color(fg)}m") + except KeyError: + raise UnknownStyleColor(color=fg) from None + + if bg: + try: + bits.append(f"\033[{_interpret_color(bg, 10)}m") + except KeyError: + raise UnknownStyleColor(color=bg) from None + + if bold is not None: + bits.append(f"\033[{1 if bold else 22}m") + if dim is not None: + bits.append(f"\033[{2 if dim else 22}m") + if underline is not None: + bits.append(f"\033[{4 if underline else 24}m") + if overline is not None: + bits.append(f"\033[{53 if overline else 55}m") + if italic is not None: + bits.append(f"\033[{3 if italic else 23}m") + if blink is not None: + bits.append(f"\033[{5 if blink else 25}m") + if reverse is not None: + bits.append(f"\033[{7 if reverse else 27}m") + if strikethrough is not None: + bits.append(f"\033[{9 if strikethrough else 29}m") + bits.append(text) + if reset: + bits.append(_ansi_reset_all) + return "".join(bits) + + +def unstyle(text: str) -> str: + r"""Remove ANSI styling information from a string. + + Usually it's not necessary to use this function as tmuxp_echo function will + automatically remove styling if necessary. + + Credit: click. + + Parameters + ---------- + text : str + Text to remove style information from. + + Returns + ------- + str + Text with ANSI codes removed. + + Examples + -------- + >>> unstyle("\033[32mgreen\033[0m") + 'green' + """ + return strip_ansi(text) diff --git a/src/tmuxp/cli/utils.py b/src/tmuxp/cli/utils.py index 64abb9b46a..0a1ca7e236 100644 --- a/src/tmuxp/cli/utils.py +++ b/src/tmuxp/cli/utils.py @@ -3,19 +3,36 @@ from __future__ import annotations import logging -import re import typing as t from tmuxp import log -from ._colors import ColorMode, Colors +from ._colors import ( + ColorMode, + Colors, + UnknownStyleColor, + strip_ansi, + style, + unstyle, +) if t.TYPE_CHECKING: from collections.abc import Callable, Sequence - from typing import TypeAlias - - CLIColour: TypeAlias = int | tuple[int, int, int] | str +# Re-export for backward compatibility +__all__ = [ + "ColorMode", + "Colors", + "UnknownStyleColor", + "prompt", + "prompt_bool", + "prompt_choices", + "prompt_yes_no", + "strip_ansi", + "style", + "tmuxp_echo", + "unstyle", +] logger = logging.getLogger(__name__) @@ -181,121 +198,3 @@ def prompt_choices( return None if rv in choices_: return rv - - -_ansi_re = re.compile(r"\033\[[;?0-9]*[a-zA-Z]") - - -def strip_ansi(value: str) -> str: - """Clear ANSI from a string value.""" - return _ansi_re.sub("", value) - - -_ansi_colors = { - "black": 30, - "red": 31, - "green": 32, - "yellow": 33, - "blue": 34, - "magenta": 35, - "cyan": 36, - "white": 37, - "reset": 39, - "bright_black": 90, - "bright_red": 91, - "bright_green": 92, - "bright_yellow": 93, - "bright_blue": 94, - "bright_magenta": 95, - "bright_cyan": 96, - "bright_white": 97, -} -_ansi_reset_all = "\033[0m" - - -def _interpret_color( - color: int | tuple[int, int, int] | str, - offset: int = 0, -) -> str: - if isinstance(color, int): - return f"{38 + offset};5;{color:d}" - - if isinstance(color, (tuple, list)): - r, g, b = color - return f"{38 + offset};2;{r:d};{g:d};{b:d}" - - return str(_ansi_colors[color] + offset) - - -class UnknownStyleColor(Exception): - """Raised when encountering an unknown terminal style color.""" - - def __init__(self, color: CLIColour, *args: object, **kwargs: object) -> None: - return super().__init__(f"Unknown color {color!r}", *args, **kwargs) - - -def style( - text: t.Any, - fg: CLIColour | None = None, - bg: CLIColour | None = None, - bold: bool | None = None, - dim: bool | None = None, - underline: bool | None = None, - overline: bool | None = None, - italic: bool | None = None, - blink: bool | None = None, - reverse: bool | None = None, - strikethrough: bool | None = None, - reset: bool = True, -) -> str: - """Credit: click.""" - if not isinstance(text, str): - text = str(text) - - bits = [] - - if fg: - try: - bits.append(f"\033[{_interpret_color(fg)}m") - except KeyError: - raise UnknownStyleColor(color=fg) from None - - if bg: - try: - bits.append(f"\033[{_interpret_color(bg, 10)}m") - except KeyError: - raise UnknownStyleColor(color=bg) from None - - if bold is not None: - bits.append(f"\033[{1 if bold else 22}m") - if dim is not None: - bits.append(f"\033[{2 if dim else 22}m") - if underline is not None: - bits.append(f"\033[{4 if underline else 24}m") - if overline is not None: - bits.append(f"\033[{53 if overline else 55}m") - if italic is not None: - bits.append(f"\033[{3 if italic else 23}m") - if blink is not None: - bits.append(f"\033[{5 if blink else 25}m") - if reverse is not None: - bits.append(f"\033[{7 if reverse else 27}m") - if strikethrough is not None: - bits.append(f"\033[{9 if strikethrough else 29}m") - bits.append(text) - if reset: - bits.append(_ansi_reset_all) - return "".join(bits) - - -def unstyle(text: str) -> str: - """Remove ANSI styling information from a string. - - Usually it's not necessary to use this function as tmuxp_echo function will - automatically remove styling if necessary. - - Credit: click. - - text : the text to remove style information from. - """ - return strip_ansi(text) From 0ffa10112f873243ba30f425673d9b3505d1f973 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 3 Jan 2026 13:40:03 -0600 Subject: [PATCH 19/99] fix(cli[debug-info]): Remove trailing newline when no stderr Only include stderr in output when it contains content, avoiding extra blank lines in debug-info output. --- src/tmuxp/cli/debug_info.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/tmuxp/cli/debug_info.py b/src/tmuxp/cli/debug_info.py index a0261dd1a9..f283918ad4 100644 --- a/src/tmuxp/cli/debug_info.py +++ b/src/tmuxp/cli/debug_info.py @@ -68,7 +68,10 @@ def format_tmux_resp(std_resp: tmux_cmd) -> str: if stderr_lines.strip(): stderr_formatted = colors.error(stderr_lines) - return "\n".join(["\n".join(stdout_lines), stderr_formatted]) + parts = ["\n".join(stdout_lines)] + if stderr_formatted: + parts.append(stderr_formatted) + return "\n".join(parts) # Build environment section with indented key-value pairs env_items = [ From 587641180f99f44d9b9096e85022f8d58d3a7e3e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 3 Jan 2026 15:04:32 -0600 Subject: [PATCH 20/99] feat(cli): Use PrivatePath for all path outputs Mask home directory in CLI path outputs for privacy. All commands now display ~/... instead of /home/user/... when showing paths. Updated: load, freeze, convert, edit, import_config --- src/tmuxp/cli/convert.py | 9 ++++++--- src/tmuxp/cli/edit.py | 3 ++- src/tmuxp/cli/freeze.py | 5 +++-- src/tmuxp/cli/import_config.py | 9 ++++++--- src/tmuxp/cli/load.py | 5 ++++- 5 files changed, 21 insertions(+), 10 deletions(-) diff --git a/src/tmuxp/cli/convert.py b/src/tmuxp/cli/convert.py index eca56f659e..71559f24f5 100644 --- a/src/tmuxp/cli/convert.py +++ b/src/tmuxp/cli/convert.py @@ -9,6 +9,7 @@ from tmuxp import exc from tmuxp._internal.config_reader import ConfigReader +from tmuxp._internal.private_path import PrivatePath from tmuxp.workspace.finders import find_workspace_file, get_workspace_dir from ._colors import Colors, get_color_mode @@ -98,10 +99,12 @@ def command_convert( if ( not answer_yes and prompt_yes_no( - f"Convert {colors.info(str(workspace_file))} to " + f"Convert {colors.info(str(PrivatePath(workspace_file)))} to " f"{colors.highlight(to_filetype)}?", ) - and prompt_yes_no(f"Save workspace to {colors.info(str(newfile))}?") + and prompt_yes_no( + f"Save workspace to {colors.info(str(PrivatePath(newfile)))}?", + ) ): answer_yes = True @@ -112,6 +115,6 @@ def command_convert( ) print( # NOQA: T201 RUF100 colors.success("New workspace file saved to ") - + colors.info(f"<{newfile}>") + + colors.info(f"<{PrivatePath(newfile)}>") + ".", ) diff --git a/src/tmuxp/cli/edit.py b/src/tmuxp/cli/edit.py index c73f448989..208cfacb58 100644 --- a/src/tmuxp/cli/edit.py +++ b/src/tmuxp/cli/edit.py @@ -6,6 +6,7 @@ import subprocess import typing as t +from tmuxp._internal.private_path import PrivatePath from tmuxp.workspace.finders import find_workspace_file from ._colors import Colors, get_color_mode @@ -45,7 +46,7 @@ def command_edit( sys_editor = os.environ.get("EDITOR", "vim") print( # NOQA: T201 RUF100 colors.muted("Opening ") - + colors.info(str(workspace_file)) + + colors.info(str(PrivatePath(workspace_file))) + colors.muted(" in ") + colors.highlight(sys_editor, bold=False) + colors.muted("..."), diff --git a/src/tmuxp/cli/freeze.py b/src/tmuxp/cli/freeze.py index f7cb4828bc..5dc7ccb895 100644 --- a/src/tmuxp/cli/freeze.py +++ b/src/tmuxp/cli/freeze.py @@ -13,6 +13,7 @@ from tmuxp import exc, util from tmuxp._internal.config_reader import ConfigReader +from tmuxp._internal.private_path import PrivatePath from tmuxp.exc import TmuxpException from tmuxp.workspace import freezer from tmuxp.workspace.finders import get_workspace_dir @@ -173,7 +174,7 @@ def command_freeze( ) if not args.force and os.path.exists(dest_prompt): print( # NOQA: T201 RUF100 - colors.warning(f"{dest_prompt} exists.") + colors.warning(f"{PrivatePath(dest_prompt)} exists.") + " " + colors.muted("Pick a new filename."), ) @@ -233,5 +234,5 @@ def extract_workspace_format( if not args.quiet: print( # NOQA: T201 RUF100 - colors.success("Saved to ") + colors.info(dest) + ".", + 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 c530245fb9..a6ff4e38a7 100644 --- a/src/tmuxp/cli/import_config.py +++ b/src/tmuxp/cli/import_config.py @@ -9,6 +9,7 @@ import typing as t from tmuxp._internal.config_reader import ConfigReader +from tmuxp._internal.private_path import PrivatePath from tmuxp.workspace import importers from tmuxp.workspace.finders import find_workspace_file @@ -181,12 +182,12 @@ def import_config( dest = None while not dest: dest_path = prompt( - f"Save to [{os.getcwd()}]", + f"Save to [{PrivatePath(os.getcwd())}]", value_proc=_resolve_path_no_overwrite, ) # dest = dest_prompt - if prompt_yes_no(f"Save to {dest_path}?"): + if prompt_yes_no(f"Save to {PrivatePath(dest_path)}?"): dest = dest_path pathlib.Path(dest).write_text( @@ -194,7 +195,9 @@ def import_config( encoding=locale.getpreferredencoding(False), ) - tmuxp_echo(colors.success("Saved to ") + colors.info(dest) + ".") + tmuxp_echo( + colors.success("Saved to ") + colors.info(str(PrivatePath(dest))) + ".", + ) else: tmuxp_echo( colors.muted("tmuxp has examples in JSON and YAML format at ") diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index bb228aedb3..ef4e0fb909 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -15,6 +15,7 @@ from tmuxp import exc, log, util from tmuxp._internal import config_reader +from tmuxp._internal.private_path import PrivatePath from tmuxp.workspace import loader from tmuxp.workspace.builder import WorkspaceBuilder from tmuxp.workspace.finders import find_workspace_file, get_workspace_dir @@ -309,7 +310,9 @@ def load_workspace( workspace_file = pathlib.Path(workspace_file) tmuxp_echo( - cli_colors.info("[Loading]") + " " + cli_colors.highlight(str(workspace_file)), + cli_colors.info("[Loading]") + + " " + + cli_colors.highlight(str(PrivatePath(workspace_file))), ) # ConfigReader allows us to open a yaml or json file as a dict From b8ac5bf92bdc99db54f11451d4ea5816ff308dcb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 3 Jan 2026 15:24:25 -0600 Subject: [PATCH 21/99] tests(cli): Add PrivatePath regression tests for path outputs Ensure CLI commands mask home directory in path outputs. Tests verify ~/... appears instead of /home/user/... Coverage for: load, freeze, convert, edit, import_config --- tests/cli/test_convert_colors.py | 49 ++++++++++++++++++++++++++++++++ tests/cli/test_edit_colors.py | 26 +++++++++++++++++ tests/cli/test_freeze_colors.py | 32 +++++++++++++++++++++ tests/cli/test_import_colors.py | 41 ++++++++++++++++++++++++++ tests/cli/test_load.py | 22 ++++++++++++++ 5 files changed, 170 insertions(+) diff --git a/tests/cli/test_convert_colors.py b/tests/cli/test_convert_colors.py index 0caa7b1d58..76596a2da7 100644 --- a/tests/cli/test_convert_colors.py +++ b/tests/cli/test_convert_colors.py @@ -2,8 +2,11 @@ from __future__ import annotations +import pathlib + import pytest +from tmuxp._internal.private_path import PrivatePath from tmuxp.cli._colors import ColorMode, Colors # Convert command color output tests @@ -96,3 +99,49 @@ def test_convert_save_prompt_format(monkeypatch: pytest.MonkeyPatch) -> None: assert "\033[36m" in prompt # cyan for file path assert newfile in prompt assert "Save workspace to" in prompt + + +# Privacy masking tests + + +def test_convert_masks_home_in_convert_prompt(monkeypatch: pytest.MonkeyPatch) -> None: + """Convert should mask home directory in convert prompt.""" + 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/.tmuxp/session.yaml") + prompt = f"Convert {colors.info(str(PrivatePath(workspace_file)))} to json?" + + assert "~/.tmuxp/session.yaml" in prompt + assert "/home/testuser" not in prompt + + +def test_convert_masks_home_in_save_prompt(monkeypatch: pytest.MonkeyPatch) -> None: + """Convert should mask home directory in save prompt.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + newfile = pathlib.Path("/home/testuser/.tmuxp/session.json") + prompt = f"Save workspace to {colors.info(str(PrivatePath(newfile)))}?" + + assert "~/.tmuxp/session.json" in prompt + assert "/home/testuser" not in prompt + + +def test_convert_masks_home_in_saved_message(monkeypatch: pytest.MonkeyPatch) -> None: + """Convert should mask home directory in saved message.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + newfile = pathlib.Path("/home/testuser/.tmuxp/session.json") + output = ( + colors.success("New workspace file saved to ") + + colors.info(f"<{PrivatePath(newfile)}>") + + "." + ) + + assert "<~/.tmuxp/session.json>" in output + assert "/home/testuser" not in output diff --git a/tests/cli/test_edit_colors.py b/tests/cli/test_edit_colors.py index db3d6bf065..8479c3fa64 100644 --- a/tests/cli/test_edit_colors.py +++ b/tests/cli/test_edit_colors.py @@ -2,8 +2,11 @@ from __future__ import annotations +import pathlib + import pytest +from tmuxp._internal.private_path import PrivatePath from tmuxp.cli._colors import ColorMode, Colors # Edit command color output tests @@ -92,3 +95,26 @@ def test_edit_various_editors(monkeypatch: pytest.MonkeyPatch) -> None: result = colors.highlight(editor, bold=False) assert "\033[35m" in result assert editor in result + + +# Privacy masking tests + + +def test_edit_masks_home_in_opening_message(monkeypatch: pytest.MonkeyPatch) -> None: + """Edit should mask home directory in 'Opening' message.""" + 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/.tmuxp/dev.yaml") + editor = "vim" + output = ( + colors.muted("Opening ") + + colors.info(str(PrivatePath(workspace_file))) + + colors.muted(" in ") + + colors.highlight(editor, bold=False) + + colors.muted("...") + ) + + assert "~/.tmuxp/dev.yaml" in output + assert "/home/testuser" not in output diff --git a/tests/cli/test_freeze_colors.py b/tests/cli/test_freeze_colors.py index fe00e3719b..785139c028 100644 --- a/tests/cli/test_freeze_colors.py +++ b/tests/cli/test_freeze_colors.py @@ -2,8 +2,11 @@ from __future__ import annotations +import pathlib + import pytest +from tmuxp._internal.private_path import PrivatePath from tmuxp.cli._colors import ColorMode, Colors # Freeze command color output tests @@ -116,3 +119,32 @@ def test_freeze_url_highlighted_in_help(monkeypatch: pytest.MonkeyPatch) -> None assert "\033[34m" in help_text # blue for muted text assert "\033[36m" in help_text # cyan for URL assert url in help_text + + +# Privacy masking tests + + +def test_freeze_masks_home_in_saved_message(monkeypatch: pytest.MonkeyPatch) -> None: + """Freeze should mask home directory in 'Saved to' message.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + dest = "/home/testuser/.tmuxp/session.yaml" + output = colors.success("Saved to ") + colors.info(str(PrivatePath(dest))) + "." + + assert "~/.tmuxp/session.yaml" in output + assert "/home/testuser" not in output + + +def test_freeze_masks_home_in_exists_warning(monkeypatch: pytest.MonkeyPatch) -> None: + """Freeze should mask home directory in 'exists' warning.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + dest_prompt = "/home/testuser/.tmuxp/session.yaml" + output = colors.warning(f"{PrivatePath(dest_prompt)} exists.") + + assert "~/.tmuxp/session.yaml exists." in output + assert "/home/testuser" not in output diff --git a/tests/cli/test_import_colors.py b/tests/cli/test_import_colors.py index b8c2b98c38..59ad3b6075 100644 --- a/tests/cli/test_import_colors.py +++ b/tests/cli/test_import_colors.py @@ -2,8 +2,11 @@ from __future__ import annotations +import pathlib + import pytest +from tmuxp._internal.private_path import PrivatePath from tmuxp.cli._colors import ColorMode, Colors # Import command color output tests @@ -122,3 +125,41 @@ def test_import_banner_with_separator(monkeypatch: pytest.MonkeyPatch) -> None: assert separator in output assert "Configuration import" in output assert config_content in output + + +# Privacy masking tests + + +def test_import_masks_home_in_save_prompt(monkeypatch: pytest.MonkeyPatch) -> None: + """Import should mask home directory in save prompt.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + cwd = "/home/testuser/projects" + prompt = f"Save to [{PrivatePath(cwd)}]" + + assert "[~/projects]" in prompt + assert "/home/testuser" not in prompt + + +def test_import_masks_home_in_confirm_prompt(monkeypatch: pytest.MonkeyPatch) -> None: + """Import should mask home directory in confirmation prompt.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + dest_path = "/home/testuser/.tmuxp/imported.yaml" + prompt = f"Save to {PrivatePath(dest_path)}?" + + assert "~/.tmuxp/imported.yaml" in prompt + assert "/home/testuser" not in prompt + + +def test_import_masks_home_in_saved_message(monkeypatch: pytest.MonkeyPatch) -> None: + """Import should mask home directory in 'Saved to' message.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + dest = "/home/testuser/.tmuxp/imported.yaml" + output = colors.success("Saved to ") + colors.info(str(PrivatePath(dest))) + "." + + assert "~/.tmuxp/imported.yaml" in output + assert "/home/testuser" not in output diff --git a/tests/cli/test_load.py b/tests/cli/test_load.py index e45bbc4f26..2191b7320c 100644 --- a/tests/cli/test_load.py +++ b/tests/cli/test_load.py @@ -16,6 +16,8 @@ from tests.fixtures import utils as test_utils 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, @@ -751,3 +753,23 @@ def test_load_append_windows_to_current_session( assert len(server.sessions) == 1 assert len(server.windows) == 6 + + +# 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.""" + 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))) + ) + + assert "~/work/project/.tmuxp.yaml" in output + assert "/home/testuser" not in output From fd9596a8cb6bb544c26e3928f970ad2282d58ffc Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 3 Jan 2026 17:24:29 -0600 Subject: [PATCH 22/99] docs(cli[load]): Add doctests to load_plugins function Adds Examples section with working doctests demonstrating: - Empty config returns empty list - Explicit Colors instance parameter Satisfies CLAUDE.md doctest requirement. --- src/tmuxp/cli/load.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index ef4e0fb909..bb7855ea5f 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -74,6 +74,21 @@ def load_plugins( ------- list List of loaded plugin instances. + + Examples + -------- + Empty config returns empty list: + + >>> from tmuxp.cli.load import load_plugins + >>> load_plugins({'session_name': 'test'}) + [] + + With explicit Colors instance: + + >>> from tmuxp.cli._colors import ColorMode, Colors + >>> colors = Colors(ColorMode.NEVER) + >>> load_plugins({'session_name': 'test'}, colors=colors) + [] """ if colors is None: colors = Colors(ColorMode.AUTO) From cf5477db0f99cab1d26b641491f59b410654d8df Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 3 Jan 2026 17:26:50 -0600 Subject: [PATCH 23/99] fix(cli[_colors]): Add RGB tuple validation in _interpret_color - Validate tuple/list has exactly 3 elements before unpacking - Catch ValueError in style() to raise UnknownStyleColor consistently - Add tests for valid RGB and invalid tuple lengths Invalid tuples now raise UnknownStyleColor instead of leaking ValueError. --- src/tmuxp/cli/_colors.py | 8 ++++++-- tests/cli/test_colors.py | 38 +++++++++++++++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/tmuxp/cli/_colors.py b/src/tmuxp/cli/_colors.py index 23d4053159..1010041703 100644 --- a/src/tmuxp/cli/_colors.py +++ b/src/tmuxp/cli/_colors.py @@ -591,6 +591,10 @@ def _interpret_color( return f"{38 + offset};5;{color:d}" if isinstance(color, (tuple, list)): + if len(color) != 3: + raise ValueError( + f"RGB color tuple must have exactly 3 values, got {len(color)}" + ) r, g, b = color return f"{38 + offset};2;{r:d};{g:d};{b:d}" @@ -678,13 +682,13 @@ def style( if fg: try: bits.append(f"\033[{_interpret_color(fg)}m") - except KeyError: + except (KeyError, ValueError): raise UnknownStyleColor(color=fg) from None if bg: try: bits.append(f"\033[{_interpret_color(bg, 10)}m") - except KeyError: + except (KeyError, ValueError): raise UnknownStyleColor(color=bg) from None if bold is not None: diff --git a/tests/cli/test_colors.py b/tests/cli/test_colors.py index 7e75a8e26a..15d2ee2396 100644 --- a/tests/cli/test_colors.py +++ b/tests/cli/test_colors.py @@ -6,7 +6,13 @@ import pytest -from tmuxp.cli._colors import ColorMode, Colors, get_color_mode +from tmuxp.cli._colors import ( + ColorMode, + Colors, + UnknownStyleColor, + get_color_mode, + style, +) # ColorMode tests @@ -233,3 +239,33 @@ def test_disabled_preserves_text() -> None: with_spaces = "some message" assert colors.success(with_spaces) == with_spaces + + +# RGB tuple validation tests + + +def test_style_with_valid_rgb_tuple() -> None: + """style() should accept valid RGB tuple.""" + result = style("test", fg=(255, 128, 0)) + assert "\033[38;2;255;128;0m" in result + assert "test" in result + + +def test_style_with_invalid_2_element_tuple() -> None: + """style() should raise UnknownStyleColor for 2-element tuple.""" + with pytest.raises(UnknownStyleColor): + style("test", fg=(255, 128)) + + +def test_style_with_invalid_4_element_tuple() -> None: + """style() should raise UnknownStyleColor for 4-element tuple.""" + with pytest.raises(UnknownStyleColor): + style("test", fg=(255, 128, 0, 64)) + + +def test_style_with_empty_tuple() -> None: + """style() treats empty tuple as 'no color' (falsy value).""" + result = style("test", fg=()) + # Empty tuple is falsy, so no fg color is applied + assert "test" in result + assert "\033[38" not in result # No foreground color escape From 8994b205e42ee237f7f0fc3ea8c860908bb25bae Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 3 Jan 2026 18:30:22 -0600 Subject: [PATCH 24/99] feat(cli): Add beautiful help output with usage examples Add usage examples to --help output for main CLI and all subcommands, following the vcspull pattern. Examples show common command patterns to help users discover functionality quickly. - Add build_description() helper to assemble intro + example blocks - Add TmuxpHelpFormatter with HelpTheme for colorized examples - Add DESCRIPTION constants with examples to all subcommands - Add test_help_examples.py to verify all examples are valid --- src/tmuxp/cli/__init__.py | 134 +++++++++++++-- src/tmuxp/cli/_colors.py | 54 +++++- src/tmuxp/cli/_formatter.py | 281 ++++++++++++++++++++++++++++++++ src/tmuxp/cli/convert.py | 18 +- src/tmuxp/cli/debug_info.py | 16 +- src/tmuxp/cli/edit.py | 17 +- src/tmuxp/cli/freeze.py | 19 ++- src/tmuxp/cli/import_config.py | 22 ++- src/tmuxp/cli/load.py | 21 ++- src/tmuxp/cli/ls.py | 16 +- src/tmuxp/cli/shell.py | 19 ++- tests/cli/test_colors.py | 6 +- tests/cli/test_help_examples.py | 234 ++++++++++++++++++++++++++ 13 files changed, 833 insertions(+), 24 deletions(-) create mode 100644 src/tmuxp/cli/_formatter.py create mode 100644 tests/cli/test_help_examples.py diff --git a/src/tmuxp/cli/__init__.py b/src/tmuxp/cli/__init__.py index aa118de160..3387c5272e 100644 --- a/src/tmuxp/cli/__init__.py +++ b/src/tmuxp/cli/__init__.py @@ -16,26 +16,111 @@ from tmuxp.__about__ import __version__ from tmuxp.log import setup_logger -from .convert import command_convert, create_convert_subparser +from ._colors import build_description +from ._formatter import HelpTheme, TmuxpHelpFormatter +from .convert import CONVERT_DESCRIPTION, command_convert, create_convert_subparser from .debug_info import ( + DEBUG_INFO_DESCRIPTION, CLIDebugInfoNamespace, command_debug_info, create_debug_info_subparser, ) -from .edit import command_edit, create_edit_subparser -from .freeze import CLIFreezeNamespace, command_freeze, create_freeze_subparser +from .edit import EDIT_DESCRIPTION, command_edit, create_edit_subparser +from .freeze import ( + FREEZE_DESCRIPTION, + CLIFreezeNamespace, + command_freeze, + create_freeze_subparser, +) from .import_config import ( + IMPORT_DESCRIPTION, command_import_teamocil, command_import_tmuxinator, create_import_subparser, ) -from .load import CLILoadNamespace, command_load, create_load_subparser -from .ls import CLILsNamespace, command_ls, create_ls_subparser -from .shell import CLIShellNamespace, command_shell, create_shell_subparser +from .load import ( + LOAD_DESCRIPTION, + CLILoadNamespace, + command_load, + create_load_subparser, +) +from .ls import LS_DESCRIPTION, CLILsNamespace, command_ls, create_ls_subparser +from .shell import ( + SHELL_DESCRIPTION, + CLIShellNamespace, + command_shell, + create_shell_subparser, +) from .utils import tmuxp_echo logger = logging.getLogger(__name__) +CLI_DESCRIPTION = build_description( + """ + tmuxp - tmux session manager. + + Manage and launch tmux sessions from YAML/JSON workspace files. + """, + ( + ( + "load", + [ + "tmuxp load myproject", + "tmuxp load ./workspace.yaml", + "tmuxp load -d myproject", + "tmuxp load -y dev staging", + ], + ), + ( + "freeze", + [ + "tmuxp freeze mysession", + "tmuxp freeze mysession -o session.yaml", + ], + ), + ( + "ls", + [ + "tmuxp ls", + ], + ), + ( + "shell", + [ + "tmuxp shell", + "tmuxp shell -L mysocket", + "tmuxp shell -c 'print(server.sessions)'", + ], + ), + ( + "convert", + [ + "tmuxp convert workspace.yaml", + "tmuxp convert workspace.json", + ], + ), + ( + "import", + [ + "tmuxp import teamocil ~/.teamocil/project.yml", + "tmuxp import tmuxinator ~/.tmuxinator/project.yml", + ], + ), + ( + "edit", + [ + "tmuxp edit myproject", + ], + ), + ( + "debug-info", + [ + "tmuxp debug-info", + ], + ), + ), +) + if t.TYPE_CHECKING: import pathlib from typing import TypeAlias @@ -57,7 +142,11 @@ def create_parser() -> argparse.ArgumentParser: """Create CLI :class:`argparse.ArgumentParser` for tmuxp.""" - parser = argparse.ArgumentParser(prog="tmuxp") + parser = argparse.ArgumentParser( + prog="tmuxp", + description=CLI_DESCRIPTION, + formatter_class=TmuxpHelpFormatter, + ) parser.add_argument( "--version", "-V", @@ -79,40 +168,65 @@ def create_parser() -> argparse.ArgumentParser: help="when to use colors: auto (default), always, or never", ) subparsers = parser.add_subparsers(dest="subparser_name") - load_parser = subparsers.add_parser("load", help="load tmuxp workspaces") + load_parser = subparsers.add_parser( + "load", + help="load tmuxp workspaces", + description=LOAD_DESCRIPTION, + formatter_class=TmuxpHelpFormatter, + ) create_load_subparser(load_parser) shell_parser = subparsers.add_parser( "shell", help="launch python shell for tmux server, session, window and pane", + description=SHELL_DESCRIPTION, + formatter_class=TmuxpHelpFormatter, ) create_shell_subparser(shell_parser) import_parser = subparsers.add_parser( "import", help="import workspaces from teamocil and tmuxinator.", + description=IMPORT_DESCRIPTION, + formatter_class=TmuxpHelpFormatter, ) create_import_subparser(import_parser) convert_parser = subparsers.add_parser( "convert", help="convert workspace files between yaml and json.", + description=CONVERT_DESCRIPTION, + formatter_class=TmuxpHelpFormatter, ) create_convert_subparser(convert_parser) debug_info_parser = subparsers.add_parser( "debug-info", help="print out all diagnostic info", + description=DEBUG_INFO_DESCRIPTION, + formatter_class=TmuxpHelpFormatter, ) create_debug_info_subparser(debug_info_parser) - ls_parser = subparsers.add_parser("ls", help="list workspaces in tmuxp directory") + ls_parser = subparsers.add_parser( + "ls", + help="list workspaces in tmuxp directory", + description=LS_DESCRIPTION, + formatter_class=TmuxpHelpFormatter, + ) create_ls_subparser(ls_parser) - edit_parser = subparsers.add_parser("edit", help="run $EDITOR on workspace file") + edit_parser = subparsers.add_parser( + "edit", + help="run $EDITOR on workspace file", + description=EDIT_DESCRIPTION, + formatter_class=TmuxpHelpFormatter, + ) create_edit_subparser(edit_parser) freeze_parser = subparsers.add_parser( "freeze", help="freeze a live tmux session to a tmuxp workspace file", + description=FREEZE_DESCRIPTION, + formatter_class=TmuxpHelpFormatter, ) create_freeze_subparser(freeze_parser) diff --git a/src/tmuxp/cli/_colors.py b/src/tmuxp/cli/_colors.py index 1010041703..fd58726590 100644 --- a/src/tmuxp/cli/_colors.py +++ b/src/tmuxp/cli/_colors.py @@ -592,9 +592,8 @@ def _interpret_color( if isinstance(color, (tuple, list)): if len(color) != 3: - raise ValueError( - f"RGB color tuple must have exactly 3 values, got {len(color)}" - ) + msg = f"RGB color tuple must have exactly 3 values, got {len(color)}" + raise ValueError(msg) r, g, b = color return f"{38 + offset};2;{r:d};{g:d};{b:d}" @@ -737,3 +736,52 @@ def unstyle(text: str) -> str: 'green' """ return strip_ansi(text) + + +def build_description( + intro: str, + example_blocks: t.Sequence[tuple[str | None, t.Sequence[str]]], +) -> str: + r"""Assemble help text with optional example sections. + + Parameters + ---------- + intro : str + The introductory description text. + example_blocks : sequence of (heading, commands) tuples + Each tuple contains an optional heading and a sequence of example commands. + If heading is None, the section is titled "examples:". + + Returns + ------- + str + Formatted description with examples. + + Examples + -------- + >>> from tmuxp.cli._colors import build_description + >>> build_description("My tool.", [(None, ["mytool run"])]) + 'My tool.\n\nexamples:\n mytool run' + + >>> build_description("My tool.", [("sync", ["mytool sync repo"])]) + 'My tool.\n\nsync examples:\n mytool sync repo' + + >>> build_description("", [(None, ["cmd"])]) + 'examples:\n cmd' + """ + import textwrap + + sections: list[str] = [] + intro_text = textwrap.dedent(intro).strip() + if intro_text: + sections.append(intro_text) + + for heading, commands in example_blocks: + if not commands: + continue + title = "examples:" if heading is None else f"{heading} examples:" + lines = [title] + lines.extend(f" {command}" for command in commands) + sections.append("\n".join(lines)) + + return "\n\n".join(sections) diff --git a/src/tmuxp/cli/_formatter.py b/src/tmuxp/cli/_formatter.py new file mode 100644 index 0000000000..290b004b76 --- /dev/null +++ b/src/tmuxp/cli/_formatter.py @@ -0,0 +1,281 @@ +"""Custom help formatter for tmuxp CLI with colorized examples. + +This module provides a custom argparse formatter that colorizes example +sections in help output, similar to vcspull's formatter. + +Examples +-------- +>>> from tmuxp.cli._formatter import TmuxpHelpFormatter +>>> TmuxpHelpFormatter # doctest: +ELLIPSIS + +""" + +from __future__ import annotations + +import argparse +import re +import typing as t + +# Options that expect a value (set externally or via --option=value) +OPTIONS_EXPECTING_VALUE = frozenset( + { + "-f", + "--file", + "-s", + "--socket-name", + "-S", + "--socket-path", + "-L", + "--log-level", + "-c", + "--command", + "-t", + "--target", + "-o", + "--output", + "-d", + "--dir", + "--color", + "-w", + "--workspace", + } +) + +# Standalone flag options (no value) +OPTIONS_FLAG_ONLY = frozenset( + { + "-h", + "--help", + "-V", + "--version", + "-y", + "--yes", + "-n", + "--no", + "-d", + "--detached", + "-2", + "-8", + "-a", + "--append", + "--json", + "--raw", + } +) + + +class TmuxpHelpFormatter(argparse.RawDescriptionHelpFormatter): + """Help formatter with colorized examples for tmuxp CLI. + + This formatter extends RawDescriptionHelpFormatter to preserve formatting + of description text while adding syntax highlighting to example sections. + + The formatter uses a `_theme` attribute (set externally) to apply colors. + If no theme is set, the formatter falls back to plain text output. + + Examples + -------- + >>> formatter = TmuxpHelpFormatter("tmuxp") + >>> formatter # doctest: +ELLIPSIS + <...TmuxpHelpFormatter object at ...> + """ + + def _fill_text(self, text: str, width: int, indent: str) -> str: + """Fill text, colorizing examples sections if theme is available. + + Parameters + ---------- + text : str + Text to format. + width : int + Maximum line width. + indent : str + Indentation prefix. + + Returns + ------- + str + Formatted text, with colorized examples if theme is set. + """ + theme = getattr(self, "_theme", None) + if not text or theme is None: + return super()._fill_text(text, width, indent) + + lines = text.splitlines(keepends=True) + formatted_lines: list[str] = [] + in_examples_block = False + expect_value = False + + for line in lines: + if line.strip() == "": + in_examples_block = False + expect_value = False + formatted_lines.append(f"{indent}{line}") + continue + + has_newline = line.endswith("\n") + stripped_line = line.rstrip("\n") + leading_length = len(stripped_line) - len(stripped_line.lstrip(" ")) + leading = stripped_line[:leading_length] + content = stripped_line[leading_length:] + content_lower = content.lower() + is_section_heading = ( + content_lower.endswith("examples:") and content_lower != "examples:" + ) + + if is_section_heading or content_lower == "examples:": + formatted_content = f"{theme.heading}{content}{theme.reset}" + in_examples_block = True + expect_value = False + elif in_examples_block: + colored_content = self._colorize_example_line( + content, + theme=theme, + expect_value=expect_value, + ) + expect_value = colored_content.expect_value + formatted_content = colored_content.text + else: + formatted_content = stripped_line + + newline = "\n" if has_newline else "" + formatted_lines.append(f"{indent}{leading}{formatted_content}{newline}") + + return "".join(formatted_lines) + + class _ColorizedLine(t.NamedTuple): + """Result of colorizing an example line.""" + + text: str + expect_value: bool + + def _colorize_example_line( + self, + content: str, + *, + theme: t.Any, + expect_value: bool, + ) -> _ColorizedLine: + """Colorize a single example command line. + + Parameters + ---------- + content : str + The line content to colorize. + theme : Any + Theme object with color attributes (prog, action, etc.). + expect_value : bool + Whether the previous token expects a value. + + Returns + ------- + _ColorizedLine + Named tuple with colorized text and updated expect_value state. + """ + parts: list[str] = [] + expecting_value = expect_value + first_token = True + colored_subcommand = False + + for match in re.finditer(r"\s+|\S+", content): + token = match.group() + if token.isspace(): + parts.append(token) + continue + + if expecting_value: + color = theme.label + expecting_value = False + elif token.startswith("--"): + color = theme.long_option + expecting_value = ( + token not in OPTIONS_FLAG_ONLY and token in OPTIONS_EXPECTING_VALUE + ) + elif token.startswith("-"): + color = theme.short_option + expecting_value = ( + token not in OPTIONS_FLAG_ONLY and token in OPTIONS_EXPECTING_VALUE + ) + elif first_token: + color = theme.prog + elif not colored_subcommand: + color = theme.action + colored_subcommand = True + else: + color = None + + first_token = False + + if color: + parts.append(f"{color}{token}{theme.reset}") + else: + parts.append(token) + + return self._ColorizedLine(text="".join(parts), expect_value=expecting_value) + + +class HelpTheme(t.NamedTuple): + """Theme colors for help output. + + Examples + -------- + >>> from tmuxp.cli._formatter import HelpTheme + >>> theme = HelpTheme.from_colors(None) + >>> theme.reset + '' + """ + + prog: str + action: str + long_option: str + short_option: str + label: str + heading: str + reset: str + + @classmethod + def from_colors(cls, colors: t.Any) -> HelpTheme: + """Create theme from Colors instance. + + Parameters + ---------- + colors : Colors | None + Colors instance, or None for no colors. + + Returns + ------- + HelpTheme + Theme with ANSI codes if colors enabled, empty strings otherwise. + + Examples + -------- + >>> from tmuxp.cli._colors import Colors, ColorMode + >>> from tmuxp.cli._formatter import HelpTheme + >>> colors = Colors(ColorMode.NEVER) + >>> theme = HelpTheme.from_colors(colors) + >>> theme.reset + '' + """ + if colors is None or not colors._enabled: + return cls( + prog="", + action="", + long_option="", + short_option="", + label="", + heading="", + reset="", + ) + + # Import style here to avoid circular import + from tmuxp.cli._colors import style + + return cls( + prog=style("", fg="magenta", bold=True).rstrip("\033[0m"), + action=style("", fg="cyan").rstrip("\033[0m"), + long_option=style("", fg="green").rstrip("\033[0m"), + short_option=style("", fg="green").rstrip("\033[0m"), + label=style("", fg="yellow").rstrip("\033[0m"), + heading=style("", fg="blue").rstrip("\033[0m"), + reset="\033[0m", + ) diff --git a/src/tmuxp/cli/convert.py b/src/tmuxp/cli/convert.py index 71559f24f5..df8b59625d 100644 --- a/src/tmuxp/cli/convert.py +++ b/src/tmuxp/cli/convert.py @@ -12,9 +12,25 @@ from tmuxp._internal.private_path import PrivatePath from tmuxp.workspace.finders import find_workspace_file, get_workspace_dir -from ._colors import Colors, get_color_mode +from ._colors import Colors, build_description, get_color_mode from .utils import prompt_yes_no +CONVERT_DESCRIPTION = build_description( + """ + Convert workspace files between YAML and JSON format. + """, + ( + ( + None, + [ + "tmuxp convert workspace.yaml", + "tmuxp convert workspace.json", + "tmuxp convert -y workspace.yaml", + ], + ), + ), +) + if t.TYPE_CHECKING: import argparse from typing import TypeAlias diff --git a/src/tmuxp/cli/debug_info.py b/src/tmuxp/cli/debug_info.py index f283918ad4..986b1df44e 100644 --- a/src/tmuxp/cli/debug_info.py +++ b/src/tmuxp/cli/debug_info.py @@ -16,9 +16,23 @@ from tmuxp.__about__ import __version__ from tmuxp._internal.private_path import PrivatePath, collapse_home_in_string -from ._colors import Colors, get_color_mode +from ._colors import Colors, build_description, get_color_mode from .utils import tmuxp_echo +DEBUG_INFO_DESCRIPTION = build_description( + """ + Print diagnostic information for debugging and issue reports. + """, + ( + ( + None, + [ + "tmuxp debug-info", + ], + ), + ), +) + if t.TYPE_CHECKING: from typing import TypeAlias diff --git a/src/tmuxp/cli/edit.py b/src/tmuxp/cli/edit.py index 208cfacb58..006ad6bb12 100644 --- a/src/tmuxp/cli/edit.py +++ b/src/tmuxp/cli/edit.py @@ -9,7 +9,22 @@ from tmuxp._internal.private_path import PrivatePath from tmuxp.workspace.finders import find_workspace_file -from ._colors import Colors, get_color_mode +from ._colors import Colors, build_description, get_color_mode + +EDIT_DESCRIPTION = build_description( + """ + Open tmuxp workspace file in your system editor ($EDITOR). + """, + ( + ( + None, + [ + "tmuxp edit myproject", + "tmuxp edit ./workspace.yaml", + ], + ), + ), +) if t.TYPE_CHECKING: import argparse diff --git a/src/tmuxp/cli/freeze.py b/src/tmuxp/cli/freeze.py index 5dc7ccb895..15a53b32a9 100644 --- a/src/tmuxp/cli/freeze.py +++ b/src/tmuxp/cli/freeze.py @@ -18,9 +18,26 @@ from tmuxp.workspace import freezer from tmuxp.workspace.finders import get_workspace_dir -from ._colors import Colors, get_color_mode +from ._colors import Colors, build_description, get_color_mode from .utils import prompt, prompt_choices, prompt_yes_no +FREEZE_DESCRIPTION = build_description( + """ + Freeze a live tmux session to a tmuxp workspace file. + """, + ( + ( + None, + [ + "tmuxp freeze mysession", + "tmuxp freeze mysession -o session.yaml", + "tmuxp freeze -f json mysession", + "tmuxp freeze -y mysession", + ], + ), + ), +) + if t.TYPE_CHECKING: from typing import TypeAlias, TypeGuard diff --git a/src/tmuxp/cli/import_config.py b/src/tmuxp/cli/import_config.py index a6ff4e38a7..2503e34bd2 100644 --- a/src/tmuxp/cli/import_config.py +++ b/src/tmuxp/cli/import_config.py @@ -13,9 +13,29 @@ from tmuxp.workspace import importers from tmuxp.workspace.finders import find_workspace_file -from ._colors import ColorMode, Colors, get_color_mode +from ._colors import ColorMode, Colors, build_description, get_color_mode from .utils import prompt, prompt_choices, prompt_yes_no, tmuxp_echo +IMPORT_DESCRIPTION = build_description( + """ + Import workspaces from teamocil and tmuxinator configuration files. + """, + ( + ( + "teamocil", + [ + "tmuxp import teamocil ~/.teamocil/project.yml", + ], + ), + ( + "tmuxinator", + [ + "tmuxp import tmuxinator ~/.tmuxinator/project.yml", + ], + ), + ), +) + if t.TYPE_CHECKING: import argparse from typing import TypeAlias diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index bb7855ea5f..da66c10ee9 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -20,9 +20,28 @@ from tmuxp.workspace.builder import WorkspaceBuilder from tmuxp.workspace.finders import find_workspace_file, get_workspace_dir -from ._colors import ColorMode, Colors, get_color_mode +from ._colors import ColorMode, Colors, build_description, get_color_mode from .utils import prompt_choices, prompt_yes_no, tmuxp_echo +LOAD_DESCRIPTION = build_description( + """ + Load tmuxp workspace file(s) and create or attach to a tmux session. + """, + ( + ( + None, + [ + "tmuxp load myproject", + "tmuxp load ./workspace.yaml", + "tmuxp load -d myproject", + "tmuxp load -y dev staging", + "tmuxp load -L other-socket myproject", + "tmuxp load -a myproject", + ], + ), + ), +) + if t.TYPE_CHECKING: from typing import TypeAlias diff --git a/src/tmuxp/cli/ls.py b/src/tmuxp/cli/ls.py index d8fab16fc1..aaa0df2829 100644 --- a/src/tmuxp/cli/ls.py +++ b/src/tmuxp/cli/ls.py @@ -9,7 +9,21 @@ from tmuxp.workspace.constants import VALID_WORKSPACE_DIR_FILE_EXTENSIONS from tmuxp.workspace.finders import get_workspace_dir -from ._colors import Colors, get_color_mode +from ._colors import Colors, build_description, get_color_mode + +LS_DESCRIPTION = build_description( + """ + List workspace files in the tmuxp configuration directory. + """, + ( + ( + None, + [ + "tmuxp ls", + ], + ), + ), +) if t.TYPE_CHECKING: from typing import TypeAlias diff --git a/src/tmuxp/cli/shell.py b/src/tmuxp/cli/shell.py index 2561865ba2..e62f0a0758 100644 --- a/src/tmuxp/cli/shell.py +++ b/src/tmuxp/cli/shell.py @@ -12,7 +12,24 @@ from tmuxp import util from tmuxp._compat import PY3, PYMINOR -from ._colors import Colors, get_color_mode +from ._colors import Colors, build_description, get_color_mode + +SHELL_DESCRIPTION = build_description( + """ + Launch interactive Python shell with tmux server, session, window and pane. + """, + ( + ( + None, + [ + "tmuxp shell", + "tmuxp shell -L mysocket", + "tmuxp shell -c 'print(server.sessions)'", + "tmuxp shell --best", + ], + ), + ), +) if t.TYPE_CHECKING: from typing import TypeAlias diff --git a/tests/cli/test_colors.py b/tests/cli/test_colors.py index 15d2ee2396..3ffc75a3c5 100644 --- a/tests/cli/test_colors.py +++ b/tests/cli/test_colors.py @@ -254,18 +254,18 @@ def test_style_with_valid_rgb_tuple() -> None: def test_style_with_invalid_2_element_tuple() -> None: """style() should raise UnknownStyleColor for 2-element tuple.""" with pytest.raises(UnknownStyleColor): - style("test", fg=(255, 128)) + style("test", fg=(255, 128)) # type: ignore[arg-type] def test_style_with_invalid_4_element_tuple() -> None: """style() should raise UnknownStyleColor for 4-element tuple.""" with pytest.raises(UnknownStyleColor): - style("test", fg=(255, 128, 0, 64)) + style("test", fg=(255, 128, 0, 64)) # type: ignore[arg-type] def test_style_with_empty_tuple() -> None: """style() treats empty tuple as 'no color' (falsy value).""" - result = style("test", fg=()) + result = style("test", fg=()) # type: ignore[arg-type] # Empty tuple is falsy, so no fg color is applied assert "test" in result assert "\033[38" not in result # No foreground color escape diff --git a/tests/cli/test_help_examples.py b/tests/cli/test_help_examples.py new file mode 100644 index 0000000000..e7768cc560 --- /dev/null +++ b/tests/cli/test_help_examples.py @@ -0,0 +1,234 @@ +"""Tests to ensure CLI help examples are valid commands.""" + +from __future__ import annotations + +import re +import subprocess + +import pytest + + +def extract_examples_from_help(help_text: str) -> list[str]: + r"""Extract example commands from help text. + + Parameters + ---------- + help_text : str + The help output text to extract examples from. + + Returns + ------- + list[str] + List of extracted example commands. + + Examples + -------- + >>> text = "load examples:\n tmuxp load myproject\n\npositions:" + >>> extract_examples_from_help(text) + ['tmuxp load myproject'] + + >>> text2 = "examples:\n tmuxp debug-info\n\noptions:" + >>> extract_examples_from_help(text2) + ['tmuxp debug-info'] + """ + examples = [] + in_examples = False + for line in help_text.splitlines(): + # Match "examples:" or "load examples:" etc. + if re.match(r"^(\S+\s+)?examples?:$", line, re.IGNORECASE): + in_examples = True + elif in_examples and line.startswith(" "): + cmd = line.strip() + if cmd.startswith("tmuxp"): + examples.append(cmd) + elif line and not line[0].isspace(): + in_examples = False + return examples + + +def test_main_help_has_examples() -> None: + """Main --help should have at least one example.""" + result = subprocess.run( + ["tmuxp", "--help"], + capture_output=True, + text=True, + check=True, + ) + examples = extract_examples_from_help(result.stdout) + assert len(examples) > 0, "Main --help should have at least one example" + + +def test_main_help_examples_are_valid_subcommands() -> None: + """All examples in main --help should reference valid subcommands.""" + result = subprocess.run( + ["tmuxp", "--help"], + capture_output=True, + text=True, + check=True, + ) + examples = extract_examples_from_help(result.stdout) + + # Extract valid subcommands from help output + valid_subcommands = { + "load", + "shell", + "import", + "convert", + "debug-info", + "ls", + "edit", + "freeze", + } + + for example in examples: + parts = example.split() + if len(parts) >= 2: + subcommand = parts[1] + assert subcommand in valid_subcommands, ( + f"Example '{example}' uses unknown subcommand '{subcommand}'" + ) + + +@pytest.mark.parametrize( + "subcommand", + [ + "load", + "shell", + "import", + "convert", + "debug-info", + "ls", + "edit", + "freeze", + ], +) +def test_subcommand_help_has_examples(subcommand: str) -> None: + """Each subcommand --help should have at least one example.""" + result = subprocess.run( + ["tmuxp", subcommand, "--help"], + capture_output=True, + text=True, + check=True, + ) + examples = extract_examples_from_help(result.stdout) + assert len(examples) > 0, f"{subcommand} --help should have at least one example" + + +def test_load_subcommand_examples_are_valid() -> None: + """Load subcommand examples should have valid flags.""" + result = subprocess.run( + ["tmuxp", "load", "--help"], + capture_output=True, + text=True, + check=True, + ) + examples = extract_examples_from_help(result.stdout) + + # Verify each example has valid structure + for example in examples: + assert example.startswith("tmuxp load"), f"Bad example format: {example}" + + +def test_freeze_subcommand_examples_are_valid() -> None: + """Freeze subcommand examples should have valid flags.""" + result = subprocess.run( + ["tmuxp", "freeze", "--help"], + capture_output=True, + text=True, + check=True, + ) + examples = extract_examples_from_help(result.stdout) + + # Verify each example has valid structure + for example in examples: + assert example.startswith("tmuxp freeze"), f"Bad example format: {example}" + + +def test_shell_subcommand_examples_are_valid() -> None: + """Shell subcommand examples should have valid flags.""" + result = subprocess.run( + ["tmuxp", "shell", "--help"], + capture_output=True, + text=True, + check=True, + ) + examples = extract_examples_from_help(result.stdout) + + # Verify each example has valid structure + for example in examples: + assert example.startswith("tmuxp shell"), f"Bad example format: {example}" + + +def test_convert_subcommand_examples_are_valid() -> None: + """Convert subcommand examples should have valid flags.""" + result = subprocess.run( + ["tmuxp", "convert", "--help"], + capture_output=True, + text=True, + check=True, + ) + examples = extract_examples_from_help(result.stdout) + + # Verify each example has valid structure + for example in examples: + assert example.startswith("tmuxp convert"), f"Bad example format: {example}" + + +def test_import_subcommand_examples_are_valid() -> None: + """Import subcommand examples should have valid flags.""" + result = subprocess.run( + ["tmuxp", "import", "--help"], + capture_output=True, + text=True, + check=True, + ) + examples = extract_examples_from_help(result.stdout) + + # Verify each example has valid structure + for example in examples: + assert example.startswith("tmuxp import"), f"Bad example format: {example}" + + +def test_edit_subcommand_examples_are_valid() -> None: + """Edit subcommand examples should have valid flags.""" + result = subprocess.run( + ["tmuxp", "edit", "--help"], + capture_output=True, + text=True, + check=True, + ) + examples = extract_examples_from_help(result.stdout) + + # Verify each example has valid structure + for example in examples: + assert example.startswith("tmuxp edit"), f"Bad example format: {example}" + + +def test_ls_subcommand_examples_are_valid() -> None: + """Ls subcommand examples should have valid flags.""" + result = subprocess.run( + ["tmuxp", "ls", "--help"], + capture_output=True, + text=True, + check=True, + ) + examples = extract_examples_from_help(result.stdout) + + # Verify each example has valid structure + for example in examples: + assert example.startswith("tmuxp ls"), f"Bad example format: {example}" + + +def test_debug_info_subcommand_examples_are_valid() -> None: + """Debug-info subcommand examples should have valid flags.""" + result = subprocess.run( + ["tmuxp", "debug-info", "--help"], + capture_output=True, + text=True, + check=True, + ) + examples = extract_examples_from_help(result.stdout) + + # Verify each example has valid structure + for example in examples: + assert example.startswith("tmuxp debug-info"), f"Bad example format: {example}" From 22d92819064033f8579449b7cc0637941c56d676 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 3 Jan 2026 19:30:12 -0600 Subject: [PATCH 25/99] docs(cli[_colors]): Complete module docstring with env var doctests The module docstring promised to demonstrate NO_COLOR and FORCE_COLOR environment variable behavior but only had an incomplete `>>> import os`. - Add monkeypatch to doctest_namespace in conftest.py - Add working doctests showing NO_COLOR disables colors in ALWAYS mode - Add working doctests showing FORCE_COLOR enables colors without TTY --- conftest.py | 2 ++ src/tmuxp/cli/_colors.py | 17 +++++++++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/conftest.py b/conftest.py index 1c6c63e6b6..05da66e402 100644 --- a/conftest.py +++ b/conftest.py @@ -103,6 +103,7 @@ def add_doctest_fixtures( request: pytest.FixtureRequest, doctest_namespace: dict[str, t.Any], tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: """Harness pytest fixtures to doctests namespace.""" if isinstance(request._pyfuncitem, DoctestItem) and shutil.which("tmux"): @@ -113,3 +114,4 @@ def add_doctest_fixtures( doctest_namespace["pane"] = session.active_pane doctest_namespace["test_utils"] = test_utils doctest_namespace["tmp_path"] = tmp_path + doctest_namespace["monkeypatch"] = monkeypatch diff --git a/src/tmuxp/cli/_colors.py b/src/tmuxp/cli/_colors.py index fd58726590..4cc5e1a85e 100644 --- a/src/tmuxp/cli/_colors.py +++ b/src/tmuxp/cli/_colors.py @@ -22,9 +22,22 @@ 'loaded' Environment variables NO_COLOR and FORCE_COLOR are respected. -NO_COLOR takes highest priority. FORCE_COLOR enables colors even without TTY: +NO_COLOR takes highest priority (disables even in ALWAYS mode): ->>> import os +>>> monkeypatch.setenv("NO_COLOR", "1") +>>> colors = Colors(ColorMode.ALWAYS) +>>> colors.success("loaded") +'loaded' + +FORCE_COLOR enables colors in AUTO mode even without TTY: + +>>> import sys +>>> monkeypatch.delenv("NO_COLOR", raising=False) +>>> monkeypatch.setenv("FORCE_COLOR", "1") +>>> monkeypatch.setattr(sys.stdout, "isatty", lambda: False) +>>> colors = Colors(ColorMode.AUTO) +>>> colors.success("loaded") # doctest: +ELLIPSIS +'...' """ from __future__ import annotations From c3ed00cde98a14f860cb0bce9de0f06a963f04a1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Jan 2026 07:40:07 -0600 Subject: [PATCH 26/99] docs(cli): Add doctests to private methods in _colors.py and _formatter.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per CLAUDE.md requirement that all functions and methods must have working doctests. Adds examples demonstrating passthrough behavior when colors are disabled or theme is absent. - Colors._colorize(): Shows enabled vs disabled behavior - _interpret_color(): Shows str/int/tuple → ANSI code conversion - TmuxpHelpFormatter._fill_text(): Shows passthrough without theme - TmuxpHelpFormatter._colorize_example_line(): Shows empty theme behavior --- src/tmuxp/cli/_colors.py | 31 +++++++++++++++++++++++++++++++ src/tmuxp/cli/_formatter.py | 22 ++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/src/tmuxp/cli/_colors.py b/src/tmuxp/cli/_colors.py index 4cc5e1a85e..a4f97759cc 100644 --- a/src/tmuxp/cli/_colors.py +++ b/src/tmuxp/cli/_colors.py @@ -196,6 +196,20 @@ def _colorize(self, text: str, fg: str, bold: bool = False) -> str: ------- str Colorized text if enabled, plain text otherwise. + + Examples + -------- + When colors are enabled, applies ANSI escape codes: + + >>> colors = Colors(ColorMode.ALWAYS) + >>> colors._colorize("test", "green") # doctest: +ELLIPSIS + '...' + + When colors are disabled, returns plain text: + + >>> colors = Colors(ColorMode.NEVER) + >>> colors._colorize("test", "green") + 'test' """ if self._enabled: return style(text, fg=fg, bold=bold) @@ -599,6 +613,23 @@ def _interpret_color( ------- str ANSI escape code parameters. + + Examples + -------- + Color name returns base ANSI code: + + >>> _interpret_color("red") + '31' + + 256-color index returns extended format: + + >>> _interpret_color(196) + '38;5;196' + + RGB tuple returns 24-bit format: + + >>> _interpret_color((255, 128, 0)) + '38;2;255;128;0' """ if isinstance(color, int): return f"{38 + offset};5;{color:d}" diff --git a/src/tmuxp/cli/_formatter.py b/src/tmuxp/cli/_formatter.py index 290b004b76..b58b8ec657 100644 --- a/src/tmuxp/cli/_formatter.py +++ b/src/tmuxp/cli/_formatter.py @@ -96,6 +96,14 @@ def _fill_text(self, text: str, width: int, indent: str) -> str: ------- str Formatted text, with colorized examples if theme is set. + + Examples + -------- + Without theme, returns text via parent formatter: + + >>> formatter = TmuxpHelpFormatter("test") + >>> formatter._fill_text("hello", 80, "") + 'hello' """ theme = getattr(self, "_theme", None) if not text or theme is None: @@ -171,6 +179,20 @@ def _colorize_example_line( ------- _ColorizedLine Named tuple with colorized text and updated expect_value state. + + Examples + -------- + With an empty theme (no colors), returns text unchanged: + + >>> formatter = TmuxpHelpFormatter("test") + >>> theme = HelpTheme.from_colors(None) + >>> result = formatter._colorize_example_line( + ... "tmuxp load", theme=theme, expect_value=False + ... ) + >>> result.text + 'tmuxp load' + >>> result.expect_value + False """ parts: list[str] = [] expecting_value = expect_value From ee0cba6f841c23cbe63dca6a8f85ce922b8c65ac Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Jan 2026 08:34:53 -0600 Subject: [PATCH 27/99] fix(cli): Propagate --color flag to prompt functions Add color_mode parameter to prompt(), prompt_bool(), prompt_yes_no(), and prompt_choices() so they respect the global --color CLI flag. Previously these functions hardcoded ColorMode.AUTO, ignoring --color=never which would still show colors on TTY. --- src/tmuxp/cli/utils.py | 46 +++++++++++++++++++++++++++++++++++------- 1 file changed, 39 insertions(+), 7 deletions(-) diff --git a/src/tmuxp/cli/utils.py b/src/tmuxp/cli/utils.py index 0a1ca7e236..8a1ea1dfa5 100644 --- a/src/tmuxp/cli/utils.py +++ b/src/tmuxp/cli/utils.py @@ -58,6 +58,8 @@ def prompt( name: str, default: str | None = None, value_proc: Callable[[str], str] | None = None, + *, + color_mode: ColorMode | None = None, ) -> str: """Return user input from command line. @@ -67,6 +69,8 @@ def prompt( prompt text default : default value if no input provided. + color_mode : + color mode for prompt styling. Defaults to AUTO if not specified. Returns ------- @@ -78,7 +82,7 @@ def prompt( `flask-script `_. See the `flask-script license `_. """ - colors = Colors(ColorMode.AUTO) + colors = Colors(color_mode if color_mode is not None else ColorMode.AUTO) prompt_ = name + ((default and " " + colors.info(f"[{default}]")) or "") prompt_ += (name.endswith("?") and " ") or ": " while True: @@ -88,7 +92,12 @@ def prompt( assert isinstance(rv, str) value_proc(rv) except ValueError as e: - return prompt(str(e), default=default, value_proc=value_proc) + return prompt( + str(e), + default=default, + value_proc=value_proc, + color_mode=color_mode, + ) if rv: return rv @@ -101,6 +110,8 @@ def prompt_bool( default: bool = False, yes_choices: Sequence[t.Any] | None = None, no_choices: Sequence[t.Any] | None = None, + *, + color_mode: ColorMode | None = None, ) -> bool: """Return True / False by prompting user input from command line. @@ -114,12 +125,14 @@ def prompt_bool( default 'y', 'yes', '1', 'on', 'true', 't' no_choices : default 'n', 'no', '0', 'off', 'false', 'f' + color_mode : + color mode for prompt styling. Defaults to AUTO if not specified. Returns ------- bool """ - colors = Colors(ColorMode.AUTO) + colors = Colors(color_mode if color_mode is not None else ColorMode.AUTO) yes_choices = yes_choices or ("y", "yes", "1", "on", "true", "t") no_choices = no_choices or ("n", "no", "0", "off", "false", "f") @@ -143,9 +156,24 @@ def prompt_bool( return False -def prompt_yes_no(name: str, default: bool = True) -> bool: - """:meth:`prompt_bool()` returning yes by default.""" - return prompt_bool(name, default=default) +def prompt_yes_no( + name: str, + default: bool = True, + *, + color_mode: ColorMode | None = None, +) -> bool: + """:meth:`prompt_bool()` returning yes by default. + + Parameters + ---------- + name : + prompt text + default : + default value if no input provided. + color_mode : + color mode for prompt styling. Defaults to AUTO if not specified. + """ + return prompt_bool(name, default=default, color_mode=color_mode) def prompt_choices( @@ -153,6 +181,8 @@ def prompt_choices( choices: list[str] | tuple[str, str], default: str | None = None, no_choice: Sequence[str] = ("none",), + *, + color_mode: ColorMode | None = None, ) -> str | None: """Return user input from command line from set of provided choices. @@ -167,12 +197,14 @@ def prompt_choices( default value if no input provided. no_choice : acceptable list of strings for "null choice" + color_mode : + color mode for prompt styling. Defaults to AUTO if not specified. Returns ------- str """ - colors = Colors(ColorMode.AUTO) + colors = Colors(color_mode if color_mode is not None else ColorMode.AUTO) choices_: list[str] = [] options: list[str] = [] From aa6efdb1097e99c8d07053555429937cf6074a55 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Jan 2026 08:36:27 -0600 Subject: [PATCH 28/99] fix(cli): Pass color_mode to prompt functions in commands Update all CLI commands to pass color_mode to prompt functions so they respect the global --color flag. --- src/tmuxp/cli/convert.py | 2 ++ src/tmuxp/cli/freeze.py | 5 ++++- src/tmuxp/cli/import_config.py | 8 +++++++- src/tmuxp/cli/load.py | 5 ++++- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/tmuxp/cli/convert.py b/src/tmuxp/cli/convert.py index df8b59625d..62cacc122e 100644 --- a/src/tmuxp/cli/convert.py +++ b/src/tmuxp/cli/convert.py @@ -117,9 +117,11 @@ def command_convert( and prompt_yes_no( f"Convert {colors.info(str(PrivatePath(workspace_file)))} to " f"{colors.highlight(to_filetype)}?", + color_mode=color_mode, ) and prompt_yes_no( f"Save workspace to {colors.info(str(PrivatePath(newfile)))}?", + color_mode=color_mode, ) ): answer_yes = True diff --git a/src/tmuxp/cli/freeze.py b/src/tmuxp/cli/freeze.py index 15a53b32a9..44dc5d3b42 100644 --- a/src/tmuxp/cli/freeze.py +++ b/src/tmuxp/cli/freeze.py @@ -161,6 +161,7 @@ def command_freeze( args.answer_yes or prompt_yes_no( "The new workspace will require adjusting afterwards. Save workspace file?", + color_mode=color_mode, ) ): if not args.quiet: @@ -188,6 +189,7 @@ def command_freeze( dest_prompt = prompt( f"Save to: {save_to}", default=save_to, + color_mode=color_mode, ) if not args.force and os.path.exists(dest_prompt): print( # NOQA: T201 RUF100 @@ -226,6 +228,7 @@ def extract_workspace_format( ), choices=t.cast("list[str]", valid_workspace_formats), default="yaml", + color_mode=color_mode, ) assert is_valid_ext(workspace_format_) workspace_format = workspace_format_ @@ -240,7 +243,7 @@ def extract_workspace_format( elif workspace_format == "json": workspace = configparser.dump(fmt="json", indent=2) - if args.answer_yes or prompt_yes_no(f"Save to {dest}?"): + if args.answer_yes or prompt_yes_no(f"Save to {dest}?", color_mode=color_mode): destdir = os.path.dirname(dest) if not os.path.isdir(destdir): os.makedirs(destdir) diff --git a/src/tmuxp/cli/import_config.py b/src/tmuxp/cli/import_config.py index 2503e34bd2..ea839dc74c 100644 --- a/src/tmuxp/cli/import_config.py +++ b/src/tmuxp/cli/import_config.py @@ -179,6 +179,7 @@ def import_config( "Convert to", choices=["yaml", "json"], default="yaml", + color_mode=colors.mode, ) if workspace_file_format == "yaml": @@ -198,16 +199,21 @@ def import_config( ) if prompt_yes_no( "The new config *WILL* require adjusting afterwards. Save config?", + color_mode=colors.mode, ): dest = None while not dest: dest_path = prompt( f"Save to [{PrivatePath(os.getcwd())}]", value_proc=_resolve_path_no_overwrite, + color_mode=colors.mode, ) # dest = dest_prompt - if prompt_yes_no(f"Save to {PrivatePath(dest_path)}?"): + if prompt_yes_no( + f"Save to {PrivatePath(dest_path)}?", + color_mode=colors.mode, + ): dest = dest_path pathlib.Path(dest).write_text( diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index da66c10ee9..d1e3e57804 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -134,6 +134,7 @@ def load_plugins( if not prompt_yes_no( f"{colors.warning(str(error))}Skip loading {plugin_name}?", default=True, + color_mode=colors.mode, ): tmuxp_echo( colors.warning("[Not Skipping]") @@ -396,6 +397,7 @@ def load_workspace( or prompt_yes_no( f"{cli_colors.highlight(session_name)} is already running. Attach?", default=True, + color_mode=cli_colors.mode, ) ): _reattach(builder, cli_colors) @@ -425,7 +427,7 @@ def load_workspace( "Or (a)ppend windows in the current active session?\n[y/n/a]" ) options = ["y", "n", "a"] - choice = prompt_choices(msg, choices=options) + choice = prompt_choices(msg, choices=options, color_mode=cli_colors.mode) if choice == "y": _load_attached(builder, detached) @@ -447,6 +449,7 @@ def load_workspace( + " (k)ill, (a)ttach, (d)etach?", choices=["k", "a", "d"], default="k", + color_mode=cli_colors.mode, ) if choice == "k": From 202153f6d30a6d09bfbe80468f2819883e3025ac Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Jan 2026 08:36:32 -0600 Subject: [PATCH 29/99] refactor(cli): Remove unused HelpTheme import --- src/tmuxp/cli/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tmuxp/cli/__init__.py b/src/tmuxp/cli/__init__.py index 3387c5272e..969ecdbd05 100644 --- a/src/tmuxp/cli/__init__.py +++ b/src/tmuxp/cli/__init__.py @@ -17,7 +17,7 @@ from tmuxp.log import setup_logger from ._colors import build_description -from ._formatter import HelpTheme, TmuxpHelpFormatter +from ._formatter import TmuxpHelpFormatter from .convert import CONVERT_DESCRIPTION, command_convert, create_convert_subparser from .debug_info import ( DEBUG_INFO_DESCRIPTION, From b7965d8f63b84f14b4358e9a8ad016ab9cd8f7b8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Jan 2026 08:37:01 -0600 Subject: [PATCH 30/99] style(cli): Use t.TypeAlias per CLAUDE.md convention Change from `from typing import TypeAlias` to using the namespace pattern `t.TypeAlias` for consistency with project conventions. --- src/tmuxp/cli/convert.py | 3 +-- src/tmuxp/cli/freeze.py | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/tmuxp/cli/convert.py b/src/tmuxp/cli/convert.py index 62cacc122e..6d038f9b1e 100644 --- a/src/tmuxp/cli/convert.py +++ b/src/tmuxp/cli/convert.py @@ -33,10 +33,9 @@ if t.TYPE_CHECKING: import argparse - from typing import TypeAlias AllowedFileTypes = t.Literal["json", "yaml"] - CLIColorModeLiteral: TypeAlias = t.Literal["auto", "always", "never"] + CLIColorModeLiteral: t.TypeAlias = t.Literal["auto", "always", "never"] def create_convert_subparser( diff --git a/src/tmuxp/cli/freeze.py b/src/tmuxp/cli/freeze.py index 44dc5d3b42..78c6c96f7a 100644 --- a/src/tmuxp/cli/freeze.py +++ b/src/tmuxp/cli/freeze.py @@ -39,10 +39,10 @@ ) if t.TYPE_CHECKING: - from typing import TypeAlias, TypeGuard + from typing import TypeGuard - CLIColorModeLiteral: TypeAlias = t.Literal["auto", "always", "never"] - CLIOutputFormatLiteral: TypeAlias = t.Literal["yaml", "json"] + CLIColorModeLiteral: t.TypeAlias = t.Literal["auto", "always", "never"] + CLIOutputFormatLiteral: t.TypeAlias = t.Literal["yaml", "json"] class CLIFreezeNamespace(argparse.Namespace): From e56d1b4c7be5a0ed7b2f05f0596a8896099dbbe1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Jan 2026 08:37:09 -0600 Subject: [PATCH 31/99] fix(cli[_colors]): Handle fg/bg=0 in style() correctly Change truthiness check from `if fg:` to `if fg or fg == 0:` to handle color index 0 (black) correctly while still treating empty values like () as "no color". --- src/tmuxp/cli/_colors.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tmuxp/cli/_colors.py b/src/tmuxp/cli/_colors.py index a4f97759cc..50dca76323 100644 --- a/src/tmuxp/cli/_colors.py +++ b/src/tmuxp/cli/_colors.py @@ -722,13 +722,13 @@ def style( bits = [] - if fg: + if fg or fg == 0: try: bits.append(f"\033[{_interpret_color(fg)}m") except (KeyError, ValueError): raise UnknownStyleColor(color=fg) from None - if bg: + if bg or bg == 0: try: bits.append(f"\033[{_interpret_color(bg, 10)}m") except (KeyError, ValueError): From ab3544170b1cb397d42ba28e79748342c0428d86 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Jan 2026 09:02:39 -0600 Subject: [PATCH 32/99] fix(cli): Handle empty input in prompt() without default When prompt() is called without a default value and the user presses Enter (empty input), the function now correctly re-prompts instead of crashing with an AssertionError. The bug occurred because: - input("") returns "" - "" or None evaluates to None - assert isinstance(None, str) fails The fix moves the value_proc validation inside a None check, allowing the loop to continue and re-prompt when no valid input is provided. --- src/tmuxp/cli/utils.py | 21 ++++++++-------- tests/cli/test_prompt_colors.py | 43 +++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/src/tmuxp/cli/utils.py b/src/tmuxp/cli/utils.py index 8a1ea1dfa5..bfb90ae5b9 100644 --- a/src/tmuxp/cli/utils.py +++ b/src/tmuxp/cli/utils.py @@ -87,22 +87,23 @@ def prompt( prompt_ += (name.endswith("?") and " ") or ": " while True: rv = input(prompt_) or default - try: - if value_proc is not None and callable(value_proc): - assert isinstance(rv, str) + # Validate with value_proc only if we have a string value + if rv is not None and value_proc is not None and callable(value_proc): + try: value_proc(rv) - except ValueError as e: - return prompt( - str(e), - default=default, - value_proc=value_proc, - color_mode=color_mode, - ) + except ValueError as e: + return prompt( + str(e), + default=default, + value_proc=value_proc, + color_mode=color_mode, + ) if rv: return rv if default is not None: return default + # No input and no default - loop to re-prompt def prompt_bool( diff --git a/tests/cli/test_prompt_colors.py b/tests/cli/test_prompt_colors.py index 3fc57be49a..90218fe660 100644 --- a/tests/cli/test_prompt_colors.py +++ b/tests/cli/test_prompt_colors.py @@ -91,3 +91,46 @@ def test_prompt_colors_disabled_returns_plain_text( assert colors.info("[/path/to/file]") == "[/path/to/file]" assert "\033[" not in colors.muted("test") assert "\033[" not in colors.info("test") + + +def test_prompt_empty_input_no_default_reprompts( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify prompt() re-prompts when user enters empty input with no default. + + This is a regression test for the bug where pressing Enter with no default + would cause an AssertionError instead of re-prompting. + """ + from tmuxp.cli.utils import prompt + + # Simulate: first input is empty (user presses Enter), second input is valid + inputs = iter(["", "valid_input"]) + monkeypatch.setattr("builtins.input", lambda _: next(inputs)) + + result = prompt("Enter value") + assert result == "valid_input" + + +def test_prompt_empty_input_with_value_proc_no_crash( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify prompt() with value_proc doesn't crash on empty input. + + This is a regression test for the AssertionError that occurred when + value_proc was provided but input was empty and no default was set. + """ + from tmuxp.cli.utils import prompt + + def validate_path(val: str) -> str: + """Validate that path is absolute.""" + if not val.startswith("/"): + msg = "Must be absolute path" + raise ValueError(msg) + return val + + # Simulate: first input is empty, second input is valid + inputs = iter(["", "/valid/path"]) + monkeypatch.setattr("builtins.input", lambda _: next(inputs)) + + result = prompt("Enter path", value_proc=validate_path) + assert result == "/valid/path" From 89c96ee4ba0c45ab13fb5931aa3d23cba3cd11a2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Jan 2026 09:03:52 -0600 Subject: [PATCH 33/99] fix(cli): Consistent PrivatePath usage in output messages - Add PrivatePath to load.py empty workspace warning - Remove extra angle brackets from convert.py success message to match freeze.py style: "Saved to ~/.tmuxp/file.json." - Update test to match new format --- src/tmuxp/cli/convert.py | 2 +- src/tmuxp/cli/load.py | 2 +- tests/cli/test_convert_colors.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/tmuxp/cli/convert.py b/src/tmuxp/cli/convert.py index 6d038f9b1e..97d2d8cd25 100644 --- a/src/tmuxp/cli/convert.py +++ b/src/tmuxp/cli/convert.py @@ -132,6 +132,6 @@ def command_convert( ) print( # NOQA: T201 RUF100 colors.success("New workspace file saved to ") - + colors.info(f"<{PrivatePath(newfile)}>") + + colors.info(str(PrivatePath(newfile))) + ".", ) diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index d1e3e57804..e55943d16f 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -384,7 +384,7 @@ def load_workspace( except exc.EmptyWorkspaceException: tmuxp_echo( cli_colors.warning("[Warning]") - + f" {workspace_file} is empty or parsed no workspace data", + + f" {PrivatePath(workspace_file)} is empty or parsed no workspace data", ) return None diff --git a/tests/cli/test_convert_colors.py b/tests/cli/test_convert_colors.py index 76596a2da7..9af42bd7ce 100644 --- a/tests/cli/test_convert_colors.py +++ b/tests/cli/test_convert_colors.py @@ -139,9 +139,9 @@ def test_convert_masks_home_in_saved_message(monkeypatch: pytest.MonkeyPatch) -> newfile = pathlib.Path("/home/testuser/.tmuxp/session.json") output = ( colors.success("New workspace file saved to ") - + colors.info(f"<{PrivatePath(newfile)}>") + + colors.info(str(PrivatePath(newfile))) + "." ) - assert "<~/.tmuxp/session.json>" in output + assert "~/.tmuxp/session.json" in output assert "/home/testuser" not in output From 0d2818c09f2092c0335b663486ddf672a1cfff64 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Jan 2026 09:07:14 -0600 Subject: [PATCH 34/99] feat(cli): Add OutputFormatter for structured JSON/NDJSON output Add output formatting utilities for CLI commands supporting: - HUMAN mode: Colored human-readable output (default) - JSON mode: Buffered array output for machine parsing - NDJSON mode: Streaming newline-delimited JSON Provides get_output_mode() to determine mode from CLI flags. Pattern based on vcspull's output module. --- src/tmuxp/cli/_output.py | 178 +++++++++++++++++++++++++++ tests/cli/test_output.py | 251 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 429 insertions(+) create mode 100644 src/tmuxp/cli/_output.py create mode 100644 tests/cli/test_output.py diff --git a/src/tmuxp/cli/_output.py b/src/tmuxp/cli/_output.py new file mode 100644 index 0000000000..d4a89d44c5 --- /dev/null +++ b/src/tmuxp/cli/_output.py @@ -0,0 +1,178 @@ +"""Output formatting utilities for tmuxp CLI. + +Provides structured output modes (JSON, NDJSON) alongside human-readable output. + +Examples +-------- +>>> from tmuxp.cli._output import OutputMode, OutputFormatter, get_output_mode + +Basic usage with human mode (default): + +>>> formatter = OutputFormatter(OutputMode.HUMAN) +>>> formatter.emit_text("Hello, world!") # doctest: +SKIP + +Get output mode from flags: + +>>> get_output_mode(json_flag=False, ndjson_flag=False) + +>>> get_output_mode(json_flag=True, ndjson_flag=False) + +>>> get_output_mode(json_flag=False, ndjson_flag=True) + + +NDJSON takes precedence over JSON: + +>>> get_output_mode(json_flag=True, ndjson_flag=True) + +""" + +from __future__ import annotations + +import enum +import json +import sys +import typing as t + + +class OutputMode(enum.Enum): + """Output format modes for CLI commands. + + Examples + -------- + >>> OutputMode.HUMAN.value + 'human' + >>> OutputMode.JSON.value + 'json' + >>> OutputMode.NDJSON.value + 'ndjson' + """ + + HUMAN = "human" + JSON = "json" + NDJSON = "ndjson" + + +class OutputFormatter: + """Manage output formatting for different modes (human, JSON, NDJSON). + + Parameters + ---------- + mode : OutputMode + The output mode to use (human, json, ndjson). Default is HUMAN. + + Examples + -------- + >>> formatter = OutputFormatter(OutputMode.JSON) + >>> formatter.mode + + + >>> formatter = OutputFormatter() + >>> formatter.mode + + """ + + def __init__(self, mode: OutputMode = OutputMode.HUMAN) -> None: + """Initialize the output formatter.""" + self.mode = mode + self._json_buffer: list[dict[str, t.Any]] = [] + + def emit(self, data: dict[str, t.Any]) -> None: + """Emit a data event. + + In NDJSON mode, immediately writes one JSON object per line. + In JSON mode, buffers data for later output as a single array. + In HUMAN mode, does nothing (use emit_text for human output). + + Parameters + ---------- + data : dict + Event data to emit as JSON. + + Examples + -------- + >>> formatter = OutputFormatter(OutputMode.JSON) + >>> formatter.emit({"name": "test", "path": "/tmp"}) + >>> len(formatter._json_buffer) + 1 + """ + if self.mode == OutputMode.NDJSON: + # Stream one JSON object per line immediately + sys.stdout.write(json.dumps(data) + "\n") + sys.stdout.flush() + elif self.mode == OutputMode.JSON: + # Buffer for later output as single array + self._json_buffer.append(data) + # Human mode: handled by specific command implementations + + def emit_text(self, text: str) -> None: + """Emit human-readable text (only in HUMAN mode). + + Parameters + ---------- + text : str + Text to output. + + Examples + -------- + >>> import io + >>> formatter = OutputFormatter(OutputMode.JSON) + >>> formatter.emit_text("This won't print") # No output in JSON mode + """ + if self.mode == OutputMode.HUMAN: + sys.stdout.write(text + "\n") + sys.stdout.flush() + + def finalize(self) -> None: + """Finalize output (flush JSON buffer if needed). + + In JSON mode, outputs the buffered data as a formatted JSON array. + In other modes, does nothing. + + Examples + -------- + >>> formatter = OutputFormatter(OutputMode.JSON) + >>> formatter.emit({"name": "test1"}) + >>> formatter.emit({"name": "test2"}) + >>> len(formatter._json_buffer) + 2 + >>> # formatter.finalize() would print the JSON array + """ + if self.mode == OutputMode.JSON and self._json_buffer: + sys.stdout.write(json.dumps(self._json_buffer, indent=2) + "\n") + sys.stdout.flush() + self._json_buffer.clear() + + +def get_output_mode(json_flag: bool, ndjson_flag: bool) -> OutputMode: + """Determine output mode from command flags. + + NDJSON takes precedence over JSON if both are specified. + + Parameters + ---------- + json_flag : bool + Whether --json was specified. + ndjson_flag : bool + Whether --ndjson was specified. + + Returns + ------- + OutputMode + The determined output mode. + + Examples + -------- + >>> get_output_mode(json_flag=False, ndjson_flag=False) + + >>> get_output_mode(json_flag=True, ndjson_flag=False) + + >>> get_output_mode(json_flag=False, ndjson_flag=True) + + >>> get_output_mode(json_flag=True, ndjson_flag=True) + + """ + if ndjson_flag: + return OutputMode.NDJSON + if json_flag: + return OutputMode.JSON + return OutputMode.HUMAN diff --git a/tests/cli/test_output.py b/tests/cli/test_output.py new file mode 100644 index 0000000000..928a67e796 --- /dev/null +++ b/tests/cli/test_output.py @@ -0,0 +1,251 @@ +"""Tests for output formatting utilities.""" + +from __future__ import annotations + +import io +import json +import sys + +import pytest + +from tmuxp.cli._output import OutputFormatter, OutputMode, get_output_mode + + +class TestOutputMode: + """Tests for OutputMode enum.""" + + def test_output_mode_values(self) -> None: + """Verify OutputMode enum values.""" + assert OutputMode.HUMAN.value == "human" + assert OutputMode.JSON.value == "json" + assert OutputMode.NDJSON.value == "ndjson" + + def test_output_mode_members(self) -> None: + """Verify all expected members exist.""" + members = list(OutputMode) + assert len(members) == 3 + assert OutputMode.HUMAN in members + assert OutputMode.JSON in members + assert OutputMode.NDJSON in members + + +class TestGetOutputMode: + """Tests for get_output_mode function.""" + + def test_default_is_human(self) -> None: + """Default mode should be HUMAN when no flags.""" + assert get_output_mode(json_flag=False, ndjson_flag=False) == OutputMode.HUMAN + + def test_json_flag(self) -> None: + """JSON flag should return JSON mode.""" + assert get_output_mode(json_flag=True, ndjson_flag=False) == OutputMode.JSON + + def test_ndjson_flag(self) -> None: + """NDJSON flag should return NDJSON mode.""" + assert get_output_mode(json_flag=False, ndjson_flag=True) == OutputMode.NDJSON + + def test_ndjson_takes_precedence(self) -> None: + """NDJSON should take precedence when both flags set.""" + assert get_output_mode(json_flag=True, ndjson_flag=True) == OutputMode.NDJSON + + +class TestOutputFormatter: + """Tests for OutputFormatter class.""" + + def test_default_mode_is_human(self) -> None: + """Default mode should be HUMAN.""" + formatter = OutputFormatter() + assert formatter.mode == OutputMode.HUMAN + + def test_explicit_mode(self) -> None: + """Mode can be set explicitly.""" + formatter = OutputFormatter(OutputMode.JSON) + assert formatter.mode == OutputMode.JSON + + def test_json_buffer_initially_empty(self) -> None: + """JSON buffer should start empty.""" + formatter = OutputFormatter(OutputMode.JSON) + assert formatter._json_buffer == [] + + +class TestOutputFormatterEmit: + """Tests for OutputFormatter.emit method.""" + + def test_emit_json_buffers_data(self) -> None: + """JSON mode should buffer data.""" + formatter = OutputFormatter(OutputMode.JSON) + formatter.emit({"name": "test1"}) + formatter.emit({"name": "test2"}) + assert len(formatter._json_buffer) == 2 + assert formatter._json_buffer[0] == {"name": "test1"} + assert formatter._json_buffer[1] == {"name": "test2"} + + def test_emit_human_does_nothing(self) -> None: + """HUMAN mode emit should not buffer or output.""" + formatter = OutputFormatter(OutputMode.HUMAN) + formatter.emit({"name": "test"}) + assert formatter._json_buffer == [] + + def test_emit_ndjson_writes_immediately( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + """NDJSON mode should write one JSON object per line immediately.""" + formatter = OutputFormatter(OutputMode.NDJSON) + formatter.emit({"name": "test1", "value": 42}) + formatter.emit({"name": "test2", "value": 43}) + + captured = capsys.readouterr() + lines = captured.out.strip().split("\n") + assert len(lines) == 2 + assert json.loads(lines[0]) == {"name": "test1", "value": 42} + assert json.loads(lines[1]) == {"name": "test2", "value": 43} + + +class TestOutputFormatterEmitText: + """Tests for OutputFormatter.emit_text method.""" + + def test_emit_text_human_outputs(self, capsys: pytest.CaptureFixture[str]) -> None: + """HUMAN mode should output text.""" + formatter = OutputFormatter(OutputMode.HUMAN) + formatter.emit_text("Hello, world!") + + captured = capsys.readouterr() + assert captured.out == "Hello, world!\n" + + def test_emit_text_json_silent(self, capsys: pytest.CaptureFixture[str]) -> None: + """JSON mode should not output text.""" + formatter = OutputFormatter(OutputMode.JSON) + formatter.emit_text("Hello, world!") + + captured = capsys.readouterr() + assert captured.out == "" + + def test_emit_text_ndjson_silent(self, capsys: pytest.CaptureFixture[str]) -> None: + """NDJSON mode should not output text.""" + formatter = OutputFormatter(OutputMode.NDJSON) + formatter.emit_text("Hello, world!") + + captured = capsys.readouterr() + assert captured.out == "" + + +class TestOutputFormatterFinalize: + """Tests for OutputFormatter.finalize method.""" + + def test_finalize_json_outputs_array( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + """JSON mode finalize should output formatted array.""" + formatter = OutputFormatter(OutputMode.JSON) + formatter.emit({"name": "test1"}) + formatter.emit({"name": "test2"}) + formatter.finalize() + + captured = capsys.readouterr() + data = json.loads(captured.out) + assert isinstance(data, list) + assert len(data) == 2 + assert data[0] == {"name": "test1"} + assert data[1] == {"name": "test2"} + + def test_finalize_json_clears_buffer(self) -> None: + """JSON mode finalize should clear the buffer.""" + formatter = OutputFormatter(OutputMode.JSON) + formatter.emit({"name": "test"}) + assert len(formatter._json_buffer) == 1 + + # Capture output to prevent test pollution + old_stdout = sys.stdout + sys.stdout = io.StringIO() + try: + formatter.finalize() + finally: + sys.stdout = old_stdout + + assert formatter._json_buffer == [] + + def test_finalize_json_empty_buffer_no_output( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + """JSON mode finalize with empty buffer should not output.""" + formatter = OutputFormatter(OutputMode.JSON) + formatter.finalize() + + captured = capsys.readouterr() + assert captured.out == "" + + def test_finalize_human_no_op(self, capsys: pytest.CaptureFixture[str]) -> None: + """HUMAN mode finalize should do nothing.""" + formatter = OutputFormatter(OutputMode.HUMAN) + formatter.finalize() + + captured = capsys.readouterr() + assert captured.out == "" + + def test_finalize_ndjson_no_op(self, capsys: pytest.CaptureFixture[str]) -> None: + """NDJSON mode finalize should do nothing (already streamed).""" + formatter = OutputFormatter(OutputMode.NDJSON) + formatter.finalize() + + captured = capsys.readouterr() + assert captured.out == "" + + +class TestOutputFormatterIntegration: + """Integration tests for OutputFormatter.""" + + def test_json_workflow(self, capsys: pytest.CaptureFixture[str]) -> None: + """Test complete JSON output workflow.""" + formatter = OutputFormatter(OutputMode.JSON) + + # Emit several records + formatter.emit({"name": "workspace1", "path": "/path/1"}) + formatter.emit({"name": "workspace2", "path": "/path/2"}) + + # Nothing output yet + captured = capsys.readouterr() + assert captured.out == "" + + # Finalize outputs everything + formatter.finalize() + captured = capsys.readouterr() + data = json.loads(captured.out) + assert len(data) == 2 + + def test_ndjson_workflow(self, capsys: pytest.CaptureFixture[str]) -> None: + """Test complete NDJSON output workflow.""" + formatter = OutputFormatter(OutputMode.NDJSON) + + # Each emit outputs immediately + formatter.emit({"name": "workspace1"}) + captured = capsys.readouterr() + assert json.loads(captured.out.strip()) == {"name": "workspace1"} + + formatter.emit({"name": "workspace2"}) + captured = capsys.readouterr() + assert json.loads(captured.out.strip()) == {"name": "workspace2"} + + # Finalize is no-op + formatter.finalize() + captured = capsys.readouterr() + assert captured.out == "" + + def test_human_workflow(self, capsys: pytest.CaptureFixture[str]) -> None: + """Test complete HUMAN output workflow.""" + formatter = OutputFormatter(OutputMode.HUMAN) + + # emit does nothing in human mode + formatter.emit({"name": "ignored"}) + + # emit_text outputs text + formatter.emit_text("Workspace: test") + formatter.emit_text(" Path: /path/to/test") + + captured = capsys.readouterr() + assert "Workspace: test" in captured.out + assert "Path: /path/to/test" in captured.out + + # Finalize is no-op + formatter.finalize() + captured = capsys.readouterr() + assert captured.out == "" From dd5b7b95b141056e486f371fe49d339c36250ccf Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Jan 2026 09:13:36 -0600 Subject: [PATCH 35/99] feat(cli): Add --json, --ndjson, and --tree options to tmuxp ls Enhance ls command with machine-readable output formats: - --json: Output workspace list as JSON array - --ndjson: Output as newline-delimited JSON (one per line) - --tree: Group workspaces by directory with colored headers Extended JSON metadata includes: - name: workspace name (file stem) - path: file path (with ~ contraction) - format: yaml or json - size: file size in bytes - mtime: modification time (ISO format) - session_name: from config if parseable Tree mode shows session_name when it differs from filename. Pattern based on vcspull list command. --- src/tmuxp/cli/ls.py | 291 +++++++++++++++++++++++++++++++++++-- tests/cli/test_ls.py | 332 +++++++++++++++++++++++++++++++++++++------ 2 files changed, 570 insertions(+), 53 deletions(-) diff --git a/src/tmuxp/cli/ls.py b/src/tmuxp/cli/ls.py index aaa0df2829..63e3884b7c 100644 --- a/src/tmuxp/cli/ls.py +++ b/src/tmuxp/cli/ls.py @@ -1,15 +1,42 @@ -"""CLI for ``tmuxp ls`` subcommand.""" +"""CLI for ``tmuxp ls`` subcommand. + +List and display workspace configuration files. + +Examples +-------- +>>> from tmuxp.cli.ls import WorkspaceInfo + +Create workspace info from file path: + +>>> import pathlib +>>> ws = WorkspaceInfo( +... name="dev", +... path="~/.tmuxp/dev.yaml", +... format="yaml", +... size=256, +... mtime="2024-01-15T10:30:00", +... session_name="development", +... ) +>>> ws["name"] +'dev' +>>> ws["format"] +'yaml' +""" from __future__ import annotations import argparse -import os +import datetime +import pathlib import typing as t +from tmuxp._internal.config_reader import ConfigReader +from tmuxp._internal.private_path import PrivatePath from tmuxp.workspace.constants import VALID_WORKSPACE_DIR_FILE_EXTENSIONS from tmuxp.workspace.finders import get_workspace_dir from ._colors import Colors, build_description, get_color_mode +from ._output import OutputFormatter, get_output_mode LS_DESCRIPTION = build_description( """ @@ -20,6 +47,15 @@ None, [ "tmuxp ls", + "tmuxp ls --tree", + ], + ), + ( + "Machine-readable output:", + [ + "tmuxp ls --json", + "tmuxp ls --ndjson", + "tmuxp ls --json | jq '.[] | .name'", ], ), ), @@ -31,32 +67,265 @@ CLIColorModeLiteral: TypeAlias = t.Literal["auto", "always", "never"] +class WorkspaceInfo(t.TypedDict): + """Workspace file information for JSON output. + + Attributes + ---------- + name : str + Workspace name (file stem without extension). + path : str + Path to workspace file (with ~ contraction). + format : str + File format (yaml or json). + size : int + File size in bytes. + mtime : str + Modification time in ISO format. + session_name : str | None + Session name from config if parseable. + """ + + name: str + path: str + format: str + size: int + mtime: str + session_name: str | None + + class CLILsNamespace(argparse.Namespace): - """Typed :class:`argparse.Namespace` for tmuxp ls command.""" + """Typed :class:`argparse.Namespace` for tmuxp ls command. + + Examples + -------- + >>> ns = CLILsNamespace() + >>> ns.color = "auto" + >>> ns.color + 'auto' + """ color: CLIColorModeLiteral + tree: bool + output_json: bool + output_ndjson: bool def create_ls_subparser( parser: argparse.ArgumentParser, ) -> argparse.ArgumentParser: - """Augment :class:`argparse.ArgumentParser` with ``ls`` subcommand.""" + """Augment :class:`argparse.ArgumentParser` with ``ls`` subcommand. + + Parameters + ---------- + parser : argparse.ArgumentParser + The parser to augment. + + Returns + ------- + argparse.ArgumentParser + The augmented parser. + + Examples + -------- + >>> import argparse + >>> parser = argparse.ArgumentParser() + >>> result = create_ls_subparser(parser) + >>> result is parser + True + """ + parser.add_argument( + "--tree", + action="store_true", + help="display workspaces grouped by directory", + ) + parser.add_argument( + "--json", + action="store_true", + dest="output_json", + help="output as JSON", + ) + parser.add_argument( + "--ndjson", + action="store_true", + dest="output_ndjson", + help="output as NDJSON (one JSON per line)", + ) return parser +def _get_workspace_info(filepath: pathlib.Path) -> WorkspaceInfo: + """Extract metadata from a workspace file. + + Parameters + ---------- + filepath : pathlib.Path + Path to the workspace file. + + Returns + ------- + WorkspaceInfo + Workspace metadata dictionary. + + Examples + -------- + >>> import tempfile + >>> import pathlib + >>> content = "session_name: test-session" + chr(10) + "windows: []" + >>> with tempfile.NamedTemporaryFile( + ... suffix='.yaml', delete=False, mode='w' + ... ) as f: + ... _ = f.write(content) + ... temp_path = pathlib.Path(f.name) + >>> info = _get_workspace_info(temp_path) + >>> info['session_name'] + 'test-session' + >>> info['format'] + 'yaml' + >>> temp_path.unlink() + """ + stat = filepath.stat() + ext = filepath.suffix.lower() + file_format = "json" if ext == ".json" else "yaml" + + # Try to extract session_name from config + session_name: str | None = None + try: + config = ConfigReader.from_file(filepath) + if isinstance(config.content, dict): + session_name = config.content.get("session_name") + except Exception: + # If we can't parse it, just skip session_name + pass + + return WorkspaceInfo( + name=filepath.stem, + path=str(PrivatePath(filepath)), + format=file_format, + size=stat.st_size, + mtime=datetime.datetime.fromtimestamp( + stat.st_mtime, + tz=datetime.timezone.utc, + ).isoformat(), + session_name=session_name, + ) + + +def _output_flat( + workspaces: list[WorkspaceInfo], + formatter: OutputFormatter, + colors: Colors, +) -> None: + """Output workspaces in flat list format. + + Parameters + ---------- + workspaces : list[WorkspaceInfo] + Workspaces to display. + formatter : OutputFormatter + Output formatter. + colors : Colors + Color manager. + """ + for ws in workspaces: + # JSON/NDJSON output + formatter.emit(dict(ws)) + + # Human output + formatter.emit_text(colors.info(ws["name"])) + + +def _output_tree( + workspaces: list[WorkspaceInfo], + formatter: OutputFormatter, + colors: Colors, +) -> None: + """Output workspaces grouped by directory (tree view). + + Parameters + ---------- + workspaces : list[WorkspaceInfo] + Workspaces to display. + formatter : OutputFormatter + Output formatter. + colors : Colors + Color manager. + """ + # Group by parent directory + by_directory: dict[str, list[WorkspaceInfo]] = {} + for ws in workspaces: + # Extract parent directory from path + parent = str(pathlib.Path(ws["path"]).parent) + by_directory.setdefault(parent, []).append(ws) + + # Output grouped + for directory in sorted(by_directory.keys()): + dir_workspaces = by_directory[directory] + + # Human output: directory header + formatter.emit_text(f"\n{colors.highlight(directory)}") + + for ws in dir_workspaces: + # JSON/NDJSON output + formatter.emit(dict(ws)) + + # Human output: indented workspace name + ws_name = ws["name"] + ws_session = ws["session_name"] + session_info = "" + if ws_session and ws_session != ws_name: + session_info = f" {colors.muted(f'→ {ws_session}')}" + formatter.emit_text(f" {colors.info(ws_name)}{session_info}") + + def command_ls( args: CLILsNamespace | None = None, parser: argparse.ArgumentParser | None = None, ) -> None: - """Entrypoint for ``tmuxp ls`` subcommand.""" + """Entrypoint for ``tmuxp ls`` subcommand. + + Parameters + ---------- + args : CLILsNamespace | None + Parsed command-line arguments. + parser : argparse.ArgumentParser | None + The argument parser (unused but required by CLI interface). + + Examples + -------- + >>> # command_ls() lists workspaces from ~/.tmuxp/ + """ # Get color mode from args or default to AUTO color_mode = get_color_mode(args.color if args else None) colors = Colors(color_mode) - tmuxp_dir = get_workspace_dir() - if os.path.exists(tmuxp_dir) and os.path.isdir(tmuxp_dir): - for f in sorted(os.listdir(tmuxp_dir)): - stem, ext = os.path.splitext(f) - if os.path.isdir(f) or ext not in VALID_WORKSPACE_DIR_FILE_EXTENSIONS: + # Determine output mode + output_json = args.output_json if args else False + output_ndjson = args.output_ndjson if args else False + tree = args.tree if args else False + output_mode = get_output_mode(output_json, output_ndjson) + formatter = OutputFormatter(output_mode) + + tmuxp_dir = pathlib.Path(get_workspace_dir()) + workspaces: list[WorkspaceInfo] = [] + + if tmuxp_dir.exists() and tmuxp_dir.is_dir(): + for f in sorted(tmuxp_dir.iterdir()): + if f.is_dir(): + continue + if f.suffix.lower() not in VALID_WORKSPACE_DIR_FILE_EXTENSIONS: continue - print(colors.info(stem)) # NOQA: T201 RUF100 + workspaces.append(_get_workspace_info(f)) + + if not workspaces: + formatter.emit_text(colors.warning("No workspaces found.")) + formatter.finalize() + return + + # Output based on mode + if tree: + _output_tree(workspaces, formatter, colors) + else: + _output_flat(workspaces, formatter, colors) + + formatter.finalize() diff --git a/tests/cli/test_ls.py b/tests/cli/test_ls.py index 1811e636c4..14b376039e 100644 --- a/tests/cli/test_ls.py +++ b/tests/cli/test_ls.py @@ -3,49 +3,297 @@ from __future__ import annotations import contextlib +import json import pathlib -import typing as t + +import pytest from tmuxp import cli +from tmuxp.cli._output import OutputMode, get_output_mode +from tmuxp.cli.ls import ( + _get_workspace_info, + create_ls_subparser, +) + + +class TestGetOutputMode: + """Tests for output mode determination.""" + + def test_default_is_human(self) -> None: + """Default mode should be HUMAN when no flags.""" + assert get_output_mode(json_flag=False, ndjson_flag=False) == OutputMode.HUMAN + + def test_json_flag(self) -> None: + """JSON flag should return JSON mode.""" + assert get_output_mode(json_flag=True, ndjson_flag=False) == OutputMode.JSON + + def test_ndjson_flag(self) -> None: + """NDJSON flag should return NDJSON mode.""" + assert get_output_mode(json_flag=False, ndjson_flag=True) == OutputMode.NDJSON + + def test_ndjson_takes_precedence(self) -> None: + """NDJSON should take precedence when both flags set.""" + assert get_output_mode(json_flag=True, ndjson_flag=True) == OutputMode.NDJSON + + +class TestWorkspaceInfo: + """Tests for workspace info extraction.""" + + def test_get_workspace_info_yaml(self, tmp_path: pathlib.Path) -> None: + """Extract metadata from YAML workspace file.""" + workspace = tmp_path / "test.yaml" + workspace.write_text("session_name: my-session\nwindows: []") + + info = _get_workspace_info(workspace) + + assert info["name"] == "test" + assert info["format"] == "yaml" + assert info["session_name"] == "my-session" + assert info["size"] > 0 + assert "T" in info["mtime"] # ISO format contains T + + def test_get_workspace_info_json(self, tmp_path: pathlib.Path) -> None: + """Extract metadata from JSON workspace file.""" + workspace = tmp_path / "test.json" + workspace.write_text('{"session_name": "json-session", "windows": []}') + + info = _get_workspace_info(workspace) + + assert info["name"] == "test" + assert info["format"] == "json" + assert info["session_name"] == "json-session" + + def test_get_workspace_info_no_session_name(self, tmp_path: pathlib.Path) -> None: + """Handle workspace without session_name.""" + workspace = tmp_path / "test.yaml" + workspace.write_text("windows: []") + + info = _get_workspace_info(workspace) + + assert info["name"] == "test" + assert info["session_name"] is None + + def test_get_workspace_info_invalid_yaml(self, tmp_path: pathlib.Path) -> None: + """Handle invalid YAML gracefully.""" + workspace = tmp_path / "test.yaml" + workspace.write_text("{{{{invalid yaml") + + info = _get_workspace_info(workspace) + + assert info["name"] == "test" + assert info["session_name"] is None # Couldn't parse, so None + + +class TestLsSubparser: + """Tests for ls subparser configuration.""" + + def test_create_ls_subparser_adds_tree_flag(self) -> None: + """Verify --tree argument is added.""" + import argparse + + parser = argparse.ArgumentParser() + create_ls_subparser(parser) + args = parser.parse_args(["--tree"]) + + assert args.tree is True + + def test_create_ls_subparser_adds_json_flag(self) -> None: + """Verify --json argument is added.""" + import argparse + + parser = argparse.ArgumentParser() + create_ls_subparser(parser) + args = parser.parse_args(["--json"]) + + assert args.output_json is True + + def test_create_ls_subparser_adds_ndjson_flag(self) -> None: + """Verify --ndjson argument is added.""" + import argparse + + parser = argparse.ArgumentParser() + create_ls_subparser(parser) + args = parser.parse_args(["--ndjson"]) + + assert args.output_ndjson is True + + +class TestLsCli: + """CLI integration tests for tmuxp ls.""" + + def test_ls_cli( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + """CLI test for tmuxp ls.""" + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / ".config")) + + filenames = [ + ".git/", + ".gitignore/", + "session_1.yaml", + "session_2.yaml", + "session_3.json", + "session_4.txt", + ] + + # should ignore: + # - directories should be ignored + # - extensions not covered in VALID_WORKSPACE_DIR_FILE_EXTENSIONS + ignored_filenames = [".git/", ".gitignore/", "session_4.txt"] + stems = [pathlib.Path(f).stem for f in filenames if f not in ignored_filenames] + + for filename in filenames: + location = tmp_path / f".tmuxp/{filename}" + if filename.endswith("/"): + location.mkdir(parents=True) + else: + location.touch() + + with contextlib.suppress(SystemExit): + cli.cli(["ls"]) + + cli_output = capsys.readouterr().out + + assert cli_output == "\n".join(stems) + "\n" + + def test_ls_json_output( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + """CLI test for tmuxp ls --json.""" + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / ".config")) + monkeypatch.delenv("NO_COLOR", raising=False) + + tmuxp_dir = tmp_path / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + (tmuxp_dir / "dev.yaml").write_text("session_name: development\nwindows: []") + (tmuxp_dir / "prod.json").write_text('{"session_name": "production"}') + + with contextlib.suppress(SystemExit): + cli.cli(["ls", "--json"]) + + output = capsys.readouterr().out + data = json.loads(output) + + assert isinstance(data, list) + assert len(data) == 2 + + names = {item["name"] for item in data} + assert names == {"dev", "prod"} + + # Verify all expected fields are present + for item in data: + assert "name" in item + assert "path" in item + assert "format" in item + assert "size" in item + assert "mtime" in item + assert "session_name" in item + + def test_ls_ndjson_output( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + """CLI test for tmuxp ls --ndjson.""" + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / ".config")) + monkeypatch.delenv("NO_COLOR", raising=False) + + tmuxp_dir = tmp_path / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + (tmuxp_dir / "ws1.yaml").write_text("session_name: s1\nwindows: []") + (tmuxp_dir / "ws2.yaml").write_text("session_name: s2\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["ls", "--ndjson"]) + + output = capsys.readouterr().out + lines = [line for line in output.strip().split("\n") if line] + + assert len(lines) == 2 + + # Each line should be valid JSON + for line in lines: + data = json.loads(line) + assert "name" in data + assert "session_name" in data + + def test_ls_tree_output( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + """CLI test for tmuxp ls --tree.""" + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / ".config")) + monkeypatch.delenv("NO_COLOR", raising=False) + + tmuxp_dir = tmp_path / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + (tmuxp_dir / "dev.yaml").write_text("session_name: development\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls", "--tree"]) + + output = capsys.readouterr().out + + # Tree mode shows directory header + assert "~/.tmuxp" in output + # And indented workspace name + assert "dev" in output + + def test_ls_empty_directory( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + """CLI test for tmuxp ls with no workspaces.""" + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / ".config")) + monkeypatch.delenv("NO_COLOR", raising=False) + + tmuxp_dir = tmp_path / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls"]) + + output = capsys.readouterr().out + assert "No workspaces found" in output + + def test_ls_tree_shows_session_name_if_different( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Tree mode shows session_name if it differs from file name.""" + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / ".config")) + monkeypatch.delenv("NO_COLOR", raising=False) + + tmuxp_dir = tmp_path / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + # File named "myfile" but session is "actual-session" + (tmuxp_dir / "myfile.yaml").write_text( + "session_name: actual-session\nwindows: []" + ) + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls", "--tree"]) + + output = capsys.readouterr().out -if t.TYPE_CHECKING: - import pytest - - -def test_ls_cli( - monkeypatch: pytest.MonkeyPatch, - tmp_path: pathlib.Path, - capsys: pytest.CaptureFixture[str], -) -> None: - """CLI test for tmuxp ls.""" - monkeypatch.setenv("HOME", str(tmp_path)) - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / ".config")) - - filenames = [ - ".git/", - ".gitignore/", - "session_1.yaml", - "session_2.yaml", - "session_3.json", - "session_4.txt", - ] - - # should ignore: - # - directories should be ignored - # - extensions not covered in VALID_WORKSPACE_DIR_FILE_EXTENSIONS - ignored_filenames = [".git/", ".gitignore/", "session_4.txt"] - stems = [pathlib.Path(f).stem for f in filenames if f not in ignored_filenames] - - for filename in filenames: - location = tmp_path / f".tmuxp/{filename}" - if filename.endswith("/"): - location.mkdir(parents=True) - else: - location.touch() - - with contextlib.suppress(SystemExit): - cli.cli(["ls"]) - - cli_output = capsys.readouterr().out - - assert cli_output == "\n".join(stems) + "\n" + assert "myfile" in output + assert "actual-session" in output From 489a9af051a22dc89bc4f575f001daa07188ba1b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Jan 2026 09:34:28 -0600 Subject: [PATCH 36/99] fix(cli[freeze]): Use PrivatePath consistently for all path displays Apply PrivatePath to all user-facing path displays in freeze command: - Line 190: "Save to:" prompt now masks home directory - Line 246: "Save to ...?" confirmation now masks home directory Add tests for prompt masking behavior in test_freeze_colors.py. --- src/tmuxp/cli/freeze.py | 7 +++++-- tests/cli/test_freeze_colors.py | 24 ++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/tmuxp/cli/freeze.py b/src/tmuxp/cli/freeze.py index 78c6c96f7a..b7e19cab09 100644 --- a/src/tmuxp/cli/freeze.py +++ b/src/tmuxp/cli/freeze.py @@ -187,7 +187,7 @@ def command_freeze( ), ) dest_prompt = prompt( - f"Save to: {save_to}", + f"Save to: {PrivatePath(save_to)}", default=save_to, color_mode=color_mode, ) @@ -243,7 +243,10 @@ def extract_workspace_format( elif workspace_format == "json": workspace = configparser.dump(fmt="json", indent=2) - if args.answer_yes or prompt_yes_no(f"Save to {dest}?", color_mode=color_mode): + if args.answer_yes or prompt_yes_no( + f"Save to {PrivatePath(dest)}?", + color_mode=color_mode, + ): destdir = os.path.dirname(dest) if not os.path.isdir(destdir): os.makedirs(destdir) diff --git a/tests/cli/test_freeze_colors.py b/tests/cli/test_freeze_colors.py index 785139c028..0774c1ebc2 100644 --- a/tests/cli/test_freeze_colors.py +++ b/tests/cli/test_freeze_colors.py @@ -148,3 +148,27 @@ def test_freeze_masks_home_in_exists_warning(monkeypatch: pytest.MonkeyPatch) -> assert "~/.tmuxp/session.yaml exists." in output assert "/home/testuser" not in output + + +def test_freeze_masks_home_in_save_to_prompt(monkeypatch: pytest.MonkeyPatch) -> None: + """Freeze should mask home directory in 'Save to:' prompt.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + save_to = "/home/testuser/.tmuxp/session.yaml" + prompt_text = f"Save to: {PrivatePath(save_to)}" + + assert "~/.tmuxp/session.yaml" in prompt_text + assert "/home/testuser" not in prompt_text + + +def test_freeze_masks_home_in_save_confirmation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Freeze should mask home directory in 'Save to ...?' confirmation.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + dest = "/home/testuser/.tmuxp/session.yaml" + prompt_text = f"Save to {PrivatePath(dest)}?" + + assert "~/.tmuxp/session.yaml" in prompt_text + assert "/home/testuser" not in prompt_text From 45259197c380206a7911f56831dc9dac51b71564 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Jan 2026 09:49:19 -0600 Subject: [PATCH 37/99] feat(cli[ls]): Show local + global workspaces with source field Add combined view of workspace files from both local directories (cwd and parents) and global directory (~/.tmuxp/). Changes: - Add `source` field to WorkspaceInfo: "local" or "global" - Import and use find_local_workspace_files() from finders - Update _output_flat() to group workspaces by source with headers - Local workspaces show path (useful since they may be in parent dirs) - Global workspaces show only name (path is always ~/.tmuxp/) - Update command_ls() to collect from both sources Tests added: - test_get_workspace_info_source_local - test_ls_finds_local_workspace_in_cwd - test_ls_finds_local_workspace_in_parent - test_ls_shows_local_and_global - test_ls_json_includes_source_for_local - test_ls_local_shows_path All existing tests updated with proper monkeypatching to avoid picking up real .tmuxp.yaml from the project directory. --- src/tmuxp/cli/ls.py | 79 ++++++++++++++----- tests/cli/test_ls.py | 184 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 242 insertions(+), 21 deletions(-) diff --git a/src/tmuxp/cli/ls.py b/src/tmuxp/cli/ls.py index 63e3884b7c..14324e424f 100644 --- a/src/tmuxp/cli/ls.py +++ b/src/tmuxp/cli/ls.py @@ -16,11 +16,12 @@ ... size=256, ... mtime="2024-01-15T10:30:00", ... session_name="development", +... source="global", ... ) >>> ws["name"] 'dev' ->>> ws["format"] -'yaml' +>>> ws["source"] +'global' """ from __future__ import annotations @@ -33,7 +34,7 @@ from tmuxp._internal.config_reader import ConfigReader from tmuxp._internal.private_path import PrivatePath from tmuxp.workspace.constants import VALID_WORKSPACE_DIR_FILE_EXTENSIONS -from tmuxp.workspace.finders import get_workspace_dir +from tmuxp.workspace.finders import find_local_workspace_files, get_workspace_dir from ._colors import Colors, build_description, get_color_mode from ._output import OutputFormatter, get_output_mode @@ -84,6 +85,8 @@ class WorkspaceInfo(t.TypedDict): Modification time in ISO format. session_name : str | None Session name from config if parseable. + source : str + Source location: "local" (cwd/parents) or "global" (~/.tmuxp/). """ name: str @@ -92,6 +95,7 @@ class WorkspaceInfo(t.TypedDict): size: int mtime: str session_name: str | None + source: str class CLILsNamespace(argparse.Namespace): @@ -154,13 +158,19 @@ def create_ls_subparser( return parser -def _get_workspace_info(filepath: pathlib.Path) -> WorkspaceInfo: +def _get_workspace_info( + filepath: pathlib.Path, + *, + source: str = "global", +) -> WorkspaceInfo: """Extract metadata from a workspace file. Parameters ---------- filepath : pathlib.Path Path to the workspace file. + source : str + Source location: "local" or "global". Default "global". Returns ------- @@ -182,6 +192,11 @@ def _get_workspace_info(filepath: pathlib.Path) -> WorkspaceInfo: 'test-session' >>> info['format'] 'yaml' + >>> info['source'] + 'global' + >>> info_local = _get_workspace_info(temp_path, source="local") + >>> info_local['source'] + 'local' >>> temp_path.unlink() """ stat = filepath.stat() @@ -208,6 +223,7 @@ def _get_workspace_info(filepath: pathlib.Path) -> WorkspaceInfo: tz=datetime.timezone.utc, ).isoformat(), session_name=session_name, + source=source, ) @@ -218,6 +234,8 @@ def _output_flat( ) -> None: """Output workspaces in flat list format. + Groups workspaces by source (local vs global) for human output. + Parameters ---------- workspaces : list[WorkspaceInfo] @@ -227,12 +245,27 @@ def _output_flat( colors : Colors Color manager. """ - for ws in workspaces: - # JSON/NDJSON output - formatter.emit(dict(ws)) - - # Human output - formatter.emit_text(colors.info(ws["name"])) + # Separate by source for human output grouping + local_workspaces = [ws for ws in workspaces if ws["source"] == "local"] + global_workspaces = [ws for ws in workspaces if ws["source"] == "global"] + + # Output local workspaces first (closest to user's context) + if local_workspaces: + formatter.emit_text(colors.muted("Local workspaces:")) + for ws in local_workspaces: + formatter.emit(dict(ws)) + formatter.emit_text( + f" {colors.info(ws['name'])} {colors.muted(ws['path'])}" + ) + + # Output global workspaces + if global_workspaces: + if local_workspaces: + formatter.emit_text("") # Blank line separator + formatter.emit_text(colors.muted("Global workspaces:")) + for ws in global_workspaces: + formatter.emit(dict(ws)) + formatter.emit_text(f" {colors.info(ws['name'])}") def _output_tree( @@ -284,6 +317,9 @@ def command_ls( ) -> None: """Entrypoint for ``tmuxp ls`` subcommand. + Lists both local workspaces (from cwd and parent directories) and + global workspaces (from ~/.tmuxp/). + Parameters ---------- args : CLILsNamespace | None @@ -293,7 +329,7 @@ def command_ls( Examples -------- - >>> # command_ls() lists workspaces from ~/.tmuxp/ + >>> # command_ls() lists workspaces from cwd/parents and ~/.tmuxp/ """ # Get color mode from args or default to AUTO color_mode = get_color_mode(args.color if args else None) @@ -306,16 +342,21 @@ def command_ls( output_mode = get_output_mode(output_json, output_ndjson) formatter = OutputFormatter(output_mode) - tmuxp_dir = pathlib.Path(get_workspace_dir()) - workspaces: list[WorkspaceInfo] = [] + # 1. Collect local workspace files (cwd and parents) + local_files = find_local_workspace_files() + workspaces: list[WorkspaceInfo] = [ + _get_workspace_info(f, source="local") for f in local_files + ] + # 2. Collect global workspace files (~/.tmuxp/) + tmuxp_dir = pathlib.Path(get_workspace_dir()) if tmuxp_dir.exists() and tmuxp_dir.is_dir(): - for f in sorted(tmuxp_dir.iterdir()): - if f.is_dir(): - continue - if f.suffix.lower() not in VALID_WORKSPACE_DIR_FILE_EXTENSIONS: - continue - workspaces.append(_get_workspace_info(f)) + workspaces.extend( + _get_workspace_info(f, source="global") + for f in sorted(tmuxp_dir.iterdir()) + if not f.is_dir() + and f.suffix.lower() in VALID_WORKSPACE_DIR_FILE_EXTENSIONS + ) if not workspaces: formatter.emit_text(colors.warning("No workspaces found.")) diff --git a/tests/cli/test_ls.py b/tests/cli/test_ls.py index 14b376039e..0b4a579dda 100644 --- a/tests/cli/test_ls.py +++ b/tests/cli/test_ls.py @@ -51,6 +51,18 @@ def test_get_workspace_info_yaml(self, tmp_path: pathlib.Path) -> None: assert info["session_name"] == "my-session" assert info["size"] > 0 assert "T" in info["mtime"] # ISO format contains T + assert info["source"] == "global" # Default source + + def test_get_workspace_info_source_local(self, tmp_path: pathlib.Path) -> None: + """Extract metadata with source=local.""" + workspace = tmp_path / ".tmuxp.yaml" + workspace.write_text("session_name: local-session\nwindows: []") + + info = _get_workspace_info(workspace, source="local") + + assert info["name"] == ".tmuxp" + assert info["source"] == "local" + assert info["session_name"] == "local-session" def test_get_workspace_info_json(self, tmp_path: pathlib.Path) -> None: """Extract metadata from JSON workspace file.""" @@ -130,6 +142,8 @@ def test_ls_cli( """CLI test for tmuxp ls.""" monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / ".config")) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(pathlib.Path, "home", lambda: tmp_path) filenames = [ ".git/", @@ -154,11 +168,14 @@ def test_ls_cli( location.touch() with contextlib.suppress(SystemExit): - cli.cli(["ls"]) + cli.cli(["--color=never", "ls"]) cli_output = capsys.readouterr().out - assert cli_output == "\n".join(stems) + "\n" + # Output now has headers, check for workspace names + assert "Global workspaces:" in cli_output + for stem in stems: + assert stem in cli_output def test_ls_json_output( self, @@ -170,6 +187,8 @@ def test_ls_json_output( monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / ".config")) monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(pathlib.Path, "home", lambda: tmp_path) tmuxp_dir = tmp_path / ".tmuxp" tmuxp_dir.mkdir(parents=True) @@ -196,6 +215,8 @@ def test_ls_json_output( assert "size" in item assert "mtime" in item assert "session_name" in item + assert "source" in item + assert item["source"] == "global" def test_ls_ndjson_output( self, @@ -207,6 +228,8 @@ def test_ls_ndjson_output( monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / ".config")) monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(pathlib.Path, "home", lambda: tmp_path) tmuxp_dir = tmp_path / ".tmuxp" tmuxp_dir.mkdir(parents=True) @@ -226,6 +249,7 @@ def test_ls_ndjson_output( data = json.loads(line) assert "name" in data assert "session_name" in data + assert "source" in data def test_ls_tree_output( self, @@ -237,6 +261,8 @@ def test_ls_tree_output( monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / ".config")) monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(pathlib.Path, "home", lambda: tmp_path) tmuxp_dir = tmp_path / ".tmuxp" tmuxp_dir.mkdir(parents=True) @@ -262,6 +288,8 @@ def test_ls_empty_directory( monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / ".config")) monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(pathlib.Path, "home", lambda: tmp_path) tmuxp_dir = tmp_path / ".tmuxp" tmuxp_dir.mkdir(parents=True) @@ -282,6 +310,8 @@ def test_ls_tree_shows_session_name_if_different( monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / ".config")) monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(pathlib.Path, "home", lambda: tmp_path) tmuxp_dir = tmp_path / ".tmuxp" tmuxp_dir.mkdir(parents=True) @@ -297,3 +327,153 @@ def test_ls_tree_shows_session_name_if_different( assert "myfile" in output assert "actual-session" in output + + +class TestLsLocalWorkspaces: + """Tests for local workspace discovery in ls command.""" + + def test_ls_finds_local_workspace_in_cwd( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Ls should find .tmuxp.yaml in current directory.""" + home = tmp_path / "home" + project = home / "project" + project.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(project) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + + (project / ".tmuxp.yaml").write_text("session_name: local\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls"]) + + output = capsys.readouterr().out + assert "Local workspaces:" in output + assert ".tmuxp" in output + + def test_ls_finds_local_workspace_in_parent( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Ls should find .tmuxp.yaml in parent directory.""" + home = tmp_path / "home" + project = home / "project" + subdir = project / "src" / "module" + subdir.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(subdir) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + + (project / ".tmuxp.yaml").write_text("session_name: parent-local\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls"]) + + output = capsys.readouterr().out + assert "Local workspaces:" in output + assert ".tmuxp" in output + + def test_ls_shows_local_and_global( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Ls should show both local and global workspaces.""" + home = tmp_path / "home" + project = home / "project" + project.mkdir(parents=True) + tmuxp_dir = home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(project) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + + # Local workspace + (project / ".tmuxp.yaml").write_text("session_name: local\nwindows: []") + # Global workspace + (tmuxp_dir / "global.yaml").write_text("session_name: global\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls"]) + + output = capsys.readouterr().out + assert "Local workspaces:" in output + assert "Global workspaces:" in output + assert ".tmuxp" in output + assert "global" in output + + def test_ls_json_includes_source_for_local( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + """JSON output should include source=local for local workspaces.""" + home = tmp_path / "home" + project = home / "project" + project.mkdir(parents=True) + tmuxp_dir = home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(project) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + + (project / ".tmuxp.yaml").write_text("session_name: local\nwindows: []") + (tmuxp_dir / "global.yaml").write_text("session_name: global\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["ls", "--json"]) + + output = capsys.readouterr().out + data = json.loads(output) + + sources = {item["source"] for item in data} + assert sources == {"local", "global"} + + local_items = [item for item in data if item["source"] == "local"] + global_items = [item for item in data if item["source"] == "global"] + + assert len(local_items) == 1 + assert len(global_items) == 1 + assert local_items[0]["session_name"] == "local" + assert global_items[0]["session_name"] == "global" + + def test_ls_local_shows_path( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Local workspaces should show their path in flat mode.""" + home = tmp_path / "home" + project = home / "project" + project.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(project) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + + (project / ".tmuxp.yaml").write_text("session_name: local\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls"]) + + output = capsys.readouterr().out + # Local workspace output shows path (with ~ contraction) + assert "~/project/.tmuxp.yaml" in output From 73e00ab0fbe2897f852b0c3cf5f33c3dfe41a590 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Jan 2026 09:55:12 -0600 Subject: [PATCH 38/99] feat(cli[ls]): Add --full flag for complete config output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add --full flag to tmuxp ls command for detailed workspace inspection. Changes: - Add --full argument to subparser - Update _get_workspace_info() with include_config parameter - Add _render_config_tree() helper for human-readable window/pane hierarchy - Update _output_flat() and _output_tree() to display config tree when full=True - JSON/NDJSON output with --full includes full parsed config object - Human output with --full shows tree: windows with layout, panes with commands Example human output with --full: dev ├── editor [main-horizontal] │ ├── pane 0: vim │ └── pane 1: git status └── shell └── pane 0 Example JSON with --full: {"name": "dev", ..., "config": {"session_name": "dev", "windows": [...]}} Tests added: - test_ls_full_flag_subparser - test_get_workspace_info_include_config - test_get_workspace_info_no_config_by_default - test_ls_json_full_includes_config - test_ls_full_tree_shows_windows - test_ls_full_flat_shows_windows - test_ls_full_without_json_no_config_in_output --- src/tmuxp/cli/ls.py | 190 +++++++++++++++++++++++++++++++++++-------- tests/cli/test_ls.py | 172 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 330 insertions(+), 32 deletions(-) diff --git a/src/tmuxp/cli/ls.py b/src/tmuxp/cli/ls.py index 14324e424f..14f98fe1b4 100644 --- a/src/tmuxp/cli/ls.py +++ b/src/tmuxp/cli/ls.py @@ -49,12 +49,14 @@ [ "tmuxp ls", "tmuxp ls --tree", + "tmuxp ls --full", ], ), ( "Machine-readable output:", [ "tmuxp ls --json", + "tmuxp ls --json --full", "tmuxp ls --ndjson", "tmuxp ls --json | jq '.[] | .name'", ], @@ -113,6 +115,7 @@ class CLILsNamespace(argparse.Namespace): tree: bool output_json: bool output_ndjson: bool + full: bool def create_ls_subparser( @@ -155,6 +158,11 @@ def create_ls_subparser( dest="output_ndjson", help="output as NDJSON (one JSON per line)", ) + parser.add_argument( + "--full", + action="store_true", + help="include full config content in output", + ) return parser @@ -162,7 +170,8 @@ def _get_workspace_info( filepath: pathlib.Path, *, source: str = "global", -) -> WorkspaceInfo: + include_config: bool = False, +) -> dict[str, t.Any]: """Extract metadata from a workspace file. Parameters @@ -171,11 +180,13 @@ def _get_workspace_info( Path to the workspace file. source : str Source location: "local" or "global". Default "global". + include_config : bool + If True, include full parsed config content. Default False. Returns ------- - WorkspaceInfo - Workspace metadata dictionary. + dict[str, Any] + Workspace metadata dictionary. Includes 'config' key when include_config=True. Examples -------- @@ -197,40 +208,136 @@ def _get_workspace_info( >>> info_local = _get_workspace_info(temp_path, source="local") >>> info_local['source'] 'local' + >>> info_full = _get_workspace_info(temp_path, include_config=True) + >>> 'config' in info_full + True + >>> info_full['config']['session_name'] + 'test-session' >>> temp_path.unlink() """ stat = filepath.stat() ext = filepath.suffix.lower() file_format = "json" if ext == ".json" else "yaml" - # Try to extract session_name from config + # Try to extract session_name and optionally full config session_name: str | None = None + config_content: dict[str, t.Any] | None = None try: config = ConfigReader.from_file(filepath) if isinstance(config.content, dict): session_name = config.content.get("session_name") + if include_config: + config_content = config.content except Exception: # If we can't parse it, just skip session_name pass - return WorkspaceInfo( - name=filepath.stem, - path=str(PrivatePath(filepath)), - format=file_format, - size=stat.st_size, - mtime=datetime.datetime.fromtimestamp( + result: dict[str, t.Any] = { + "name": filepath.stem, + "path": str(PrivatePath(filepath)), + "format": file_format, + "size": stat.st_size, + "mtime": datetime.datetime.fromtimestamp( stat.st_mtime, tz=datetime.timezone.utc, ).isoformat(), - session_name=session_name, - source=source, - ) + "session_name": session_name, + "source": source, + } + + if include_config: + result["config"] = config_content + + return result + + +def _render_config_tree(config: dict[str, t.Any], colors: Colors) -> list[str]: + """Render config windows/panes as tree lines for human output. + + Parameters + ---------- + config : dict[str, Any] + Parsed config content. + colors : Colors + Color manager. + + Returns + ------- + list[str] + Lines of formatted tree output. + + Examples + -------- + >>> from tmuxp.cli._colors import ColorMode, Colors + >>> colors = Colors(ColorMode.NEVER) + >>> config = { + ... "session_name": "dev", + ... "windows": [ + ... {"window_name": "editor", "layout": "main-horizontal"}, + ... {"window_name": "shell"}, + ... ], + ... } + >>> lines = _render_config_tree(config, colors) + >>> "editor" in lines[0] + True + >>> "shell" in lines[1] + True + """ + lines: list[str] = [] + windows = config.get("windows", []) + + for i, window in enumerate(windows): + if not isinstance(window, dict): + continue + + is_last_window = i == len(windows) - 1 + prefix = "└── " if is_last_window else "├── " + child_prefix = " " if is_last_window else "│ " + + # Window line + window_name = window.get("window_name", f"window {i}") + layout = window.get("layout", "") + layout_info = f" [{layout}]" if layout else "" + lines.append(f"{prefix}{colors.info(window_name)}{colors.muted(layout_info)}") + + # Panes + panes = window.get("panes", []) + for j, pane in enumerate(panes): + is_last_pane = j == len(panes) - 1 + pane_prefix = "└── " if is_last_pane else "├── " + + # Get pane command summary + if isinstance(pane, dict): + cmds = pane.get("shell_command", []) + if isinstance(cmds, str): + cmd_str = cmds + elif isinstance(cmds, list) and cmds: + cmd_str = str(cmds[0]) + else: + cmd_str = "" + elif isinstance(pane, str): + cmd_str = pane + else: + cmd_str = "" + + # Truncate long commands + if len(cmd_str) > 40: + cmd_str = cmd_str[:37] + "..." + + pane_info = f": {cmd_str}" if cmd_str else "" + lines.append( + f"{child_prefix}{pane_prefix}{colors.muted(f'pane {j}')}{pane_info}" + ) + + return lines def _output_flat( - workspaces: list[WorkspaceInfo], + workspaces: list[dict[str, t.Any]], formatter: OutputFormatter, colors: Colors, + *, + full: bool = False, ) -> None: """Output workspaces in flat list format. @@ -238,25 +345,35 @@ def _output_flat( Parameters ---------- - workspaces : list[WorkspaceInfo] + workspaces : list[dict[str, Any]] Workspaces to display. formatter : OutputFormatter Output formatter. colors : Colors Color manager. + full : bool + If True, show full config details in tree format. Default False. """ # Separate by source for human output grouping local_workspaces = [ws for ws in workspaces if ws["source"] == "local"] global_workspaces = [ws for ws in workspaces if ws["source"] == "global"] + def output_workspace(ws: dict[str, t.Any], show_path: bool) -> None: + """Output a single workspace.""" + formatter.emit(ws) + path_info = f" {colors.muted(ws['path'])}" if show_path else "" + formatter.emit_text(f" {colors.info(ws['name'])}{path_info}") + + # With --full, show config tree + if full and ws.get("config"): + for line in _render_config_tree(ws["config"], colors): + formatter.emit_text(f" {line}") + # Output local workspaces first (closest to user's context) if local_workspaces: formatter.emit_text(colors.muted("Local workspaces:")) for ws in local_workspaces: - formatter.emit(dict(ws)) - formatter.emit_text( - f" {colors.info(ws['name'])} {colors.muted(ws['path'])}" - ) + output_workspace(ws, show_path=True) # Output global workspaces if global_workspaces: @@ -264,28 +381,31 @@ def _output_flat( formatter.emit_text("") # Blank line separator formatter.emit_text(colors.muted("Global workspaces:")) for ws in global_workspaces: - formatter.emit(dict(ws)) - formatter.emit_text(f" {colors.info(ws['name'])}") + output_workspace(ws, show_path=False) def _output_tree( - workspaces: list[WorkspaceInfo], + workspaces: list[dict[str, t.Any]], formatter: OutputFormatter, colors: Colors, + *, + full: bool = False, ) -> None: """Output workspaces grouped by directory (tree view). Parameters ---------- - workspaces : list[WorkspaceInfo] + workspaces : list[dict[str, Any]] Workspaces to display. formatter : OutputFormatter Output formatter. colors : Colors Color manager. + full : bool + If True, show full config details in tree format. Default False. """ # Group by parent directory - by_directory: dict[str, list[WorkspaceInfo]] = {} + by_directory: dict[str, list[dict[str, t.Any]]] = {} for ws in workspaces: # Extract parent directory from path parent = str(pathlib.Path(ws["path"]).parent) @@ -300,16 +420,21 @@ def _output_tree( for ws in dir_workspaces: # JSON/NDJSON output - formatter.emit(dict(ws)) + formatter.emit(ws) # Human output: indented workspace name ws_name = ws["name"] - ws_session = ws["session_name"] + ws_session = ws.get("session_name") session_info = "" if ws_session and ws_session != ws_name: session_info = f" {colors.muted(f'→ {ws_session}')}" formatter.emit_text(f" {colors.info(ws_name)}{session_info}") + # With --full, show config tree + if full and ws.get("config"): + for line in _render_config_tree(ws["config"], colors): + formatter.emit_text(f" {line}") + def command_ls( args: CLILsNamespace | None = None, @@ -335,24 +460,25 @@ def command_ls( color_mode = get_color_mode(args.color if args else None) colors = Colors(color_mode) - # Determine output mode + # Determine output mode and options output_json = args.output_json if args else False output_ndjson = args.output_ndjson if args else False tree = args.tree if args else False + full = args.full if args else False output_mode = get_output_mode(output_json, output_ndjson) formatter = OutputFormatter(output_mode) # 1. Collect local workspace files (cwd and parents) local_files = find_local_workspace_files() - workspaces: list[WorkspaceInfo] = [ - _get_workspace_info(f, source="local") for f in local_files + workspaces: list[dict[str, t.Any]] = [ + _get_workspace_info(f, source="local", include_config=full) for f in local_files ] # 2. Collect global workspace files (~/.tmuxp/) tmuxp_dir = pathlib.Path(get_workspace_dir()) if tmuxp_dir.exists() and tmuxp_dir.is_dir(): workspaces.extend( - _get_workspace_info(f, source="global") + _get_workspace_info(f, source="global", include_config=full) for f in sorted(tmuxp_dir.iterdir()) if not f.is_dir() and f.suffix.lower() in VALID_WORKSPACE_DIR_FILE_EXTENSIONS @@ -365,8 +491,8 @@ def command_ls( # Output based on mode if tree: - _output_tree(workspaces, formatter, colors) + _output_tree(workspaces, formatter, colors, full=full) else: - _output_flat(workspaces, formatter, colors) + _output_flat(workspaces, formatter, colors, full=full) formatter.finalize() diff --git a/tests/cli/test_ls.py b/tests/cli/test_ls.py index 0b4a579dda..5f2914854e 100644 --- a/tests/cli/test_ls.py +++ b/tests/cli/test_ls.py @@ -477,3 +477,175 @@ def test_ls_local_shows_path( output = capsys.readouterr().out # Local workspace output shows path (with ~ contraction) assert "~/project/.tmuxp.yaml" in output + + +class TestLsFullFlag: + """Tests for --full flag in ls command.""" + + def test_ls_full_flag_subparser(self) -> None: + """Verify --full argument is added to subparser.""" + import argparse + + from tmuxp.cli.ls import create_ls_subparser + + parser = argparse.ArgumentParser() + create_ls_subparser(parser) + args = parser.parse_args(["--full"]) + + assert args.full is True + + def test_get_workspace_info_include_config( + self, + tmp_path: pathlib.Path, + ) -> None: + """Test _get_workspace_info with include_config=True.""" + workspace = tmp_path / "test.yaml" + workspace.write_text("session_name: test\nwindows:\n - window_name: editor\n") + + info = _get_workspace_info(workspace, include_config=True) + + assert "config" in info + assert info["config"]["session_name"] == "test" + assert len(info["config"]["windows"]) == 1 + + def test_get_workspace_info_no_config_by_default( + self, + tmp_path: pathlib.Path, + ) -> None: + """Test _get_workspace_info without include_config doesn't include config.""" + workspace = tmp_path / "test.yaml" + workspace.write_text("session_name: test\nwindows: []\n") + + info = _get_workspace_info(workspace) + + assert "config" not in info + + def test_ls_json_full_includes_config( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + """JSON output with --full includes config content.""" + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / ".config")) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(pathlib.Path, "home", lambda: tmp_path) + + tmuxp_dir = tmp_path / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + (tmuxp_dir / "dev.yaml").write_text( + "session_name: dev\n" + "windows:\n" + " - window_name: editor\n" + " panes:\n" + " - vim\n" + ) + + with contextlib.suppress(SystemExit): + cli.cli(["ls", "--json", "--full"]) + + output = capsys.readouterr().out + data = json.loads(output) + + assert len(data) == 1 + assert "config" in data[0] + assert data[0]["config"]["session_name"] == "dev" + assert data[0]["config"]["windows"][0]["window_name"] == "editor" + + def test_ls_full_tree_shows_windows( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Tree mode with --full shows window/pane hierarchy.""" + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / ".config")) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(pathlib.Path, "home", lambda: tmp_path) + monkeypatch.delenv("NO_COLOR", raising=False) + + tmuxp_dir = tmp_path / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + (tmuxp_dir / "dev.yaml").write_text( + "session_name: dev\n" + "windows:\n" + " - window_name: editor\n" + " layout: main-horizontal\n" + " panes:\n" + " - vim\n" + " - window_name: shell\n" + ) + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls", "--tree", "--full"]) + + output = capsys.readouterr().out + + assert "dev" in output + assert "editor" in output + assert "main-horizontal" in output + assert "shell" in output + assert "pane 0" in output + + def test_ls_full_flat_shows_windows( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Flat mode with --full shows window/pane hierarchy.""" + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / ".config")) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(pathlib.Path, "home", lambda: tmp_path) + monkeypatch.delenv("NO_COLOR", raising=False) + + tmuxp_dir = tmp_path / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + (tmuxp_dir / "dev.yaml").write_text( + "session_name: dev\n" + "windows:\n" + " - window_name: code\n" + " panes:\n" + " - nvim\n" + ) + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls", "--full"]) + + output = capsys.readouterr().out + + assert "Global workspaces:" in output + assert "dev" in output + assert "code" in output + assert "pane 0" in output + + def test_ls_full_without_json_no_config_in_output( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Non-JSON with --full shows tree but not raw config.""" + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / ".config")) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(pathlib.Path, "home", lambda: tmp_path) + monkeypatch.delenv("NO_COLOR", raising=False) + + tmuxp_dir = tmp_path / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + (tmuxp_dir / "dev.yaml").write_text( + "session_name: dev\nwindows:\n - window_name: editor\n" + ) + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls", "--full"]) + + output = capsys.readouterr().out + + # Should show tree structure, not raw config keys + assert "editor" in output + assert "session_name:" not in output # Raw YAML not in output From af4a683667033a60abecb077d779b0c23697d9be Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Jan 2026 10:16:48 -0600 Subject: [PATCH 39/99] feat(cli[search]): Add core search module with token parsing Add foundation for tmuxp search command inspired by vcspull's search: - SearchToken and SearchPattern types for query representation - Field alias support (n:, s:, p:, w: shortcuts) - Smart parsing: unknown prefixes treated as literal patterns - Regex compilation with ignore_case, smart_case, fixed_strings, word_regexp - InvalidFieldError for explicit --field validation --- src/tmuxp/cli/search.py | 439 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 439 insertions(+) create mode 100644 src/tmuxp/cli/search.py diff --git a/src/tmuxp/cli/search.py b/src/tmuxp/cli/search.py new file mode 100644 index 0000000000..959485675b --- /dev/null +++ b/src/tmuxp/cli/search.py @@ -0,0 +1,439 @@ +"""CLI for ``tmuxp search`` subcommand. + +Search workspace configuration files by name, session, path, and content. + +Examples +-------- +>>> from tmuxp.cli.search import SearchToken, normalize_fields + +Parse field aliases to canonical names: + +>>> normalize_fields(["s", "name"]) +('session_name', 'name') + +Create search tokens from query terms: + +>>> from tmuxp.cli.search import parse_query_terms, DEFAULT_FIELDS +>>> tokens = parse_query_terms(["name:dev", "editor"], default_fields=DEFAULT_FIELDS) +>>> tokens[0] +SearchToken(fields=('name',), pattern='dev') +>>> tokens[1] +SearchToken(fields=('name', 'session_name', 'path'), pattern='editor') +""" + +from __future__ import annotations + +import re +import typing as t + +if t.TYPE_CHECKING: + pass + +#: Field name aliases for search queries +FIELD_ALIASES: dict[str, str] = { + "name": "name", + "n": "name", + "session": "session_name", + "session_name": "session_name", + "s": "session_name", + "path": "path", + "p": "path", + "window": "window", + "w": "window", + "pane": "pane", +} + +#: Valid field names after alias resolution +VALID_FIELDS: frozenset[str] = frozenset( + {"name", "session_name", "path", "window", "pane"} +) + +#: Default fields to search when no field prefix is specified +DEFAULT_FIELDS: tuple[str, ...] = ("name", "session_name", "path") + + +class SearchToken(t.NamedTuple): + """Parsed search token with target fields and raw pattern. + + Attributes + ---------- + fields : tuple[str, ...] + Canonical field names to search (e.g., ('name', 'session_name')). + pattern : str + Raw search pattern before regex compilation. + + Examples + -------- + >>> token = SearchToken(fields=("name",), pattern="dev") + >>> token.fields + ('name',) + >>> token.pattern + 'dev' + """ + + fields: tuple[str, ...] + pattern: str + + +class SearchPattern(t.NamedTuple): + """Compiled search pattern with regex and metadata. + + Attributes + ---------- + fields : tuple[str, ...] + Canonical field names to search. + raw : str + Original pattern string before compilation. + regex : re.Pattern[str] + Compiled regex pattern for matching. + + Examples + -------- + >>> import re + >>> pattern = SearchPattern( + ... fields=("name",), + ... raw="dev", + ... regex=re.compile("dev"), + ... ) + >>> pattern.fields + ('name',) + >>> bool(pattern.regex.search("development")) + True + """ + + fields: tuple[str, ...] + raw: str + regex: re.Pattern[str] + + +class InvalidFieldError(ValueError): + """Raised when an invalid field name is specified. + + Examples + -------- + >>> raise InvalidFieldError("invalid") + Traceback (most recent call last): + ... + tmuxp.cli.search.InvalidFieldError: Unknown search field: 'invalid'. ... + """ + + def __init__(self, field: str) -> None: + valid = ", ".join(sorted(FIELD_ALIASES.keys())) + super().__init__(f"Unknown search field: '{field}'. Valid fields: {valid}") + self.field = field + + +def normalize_fields(fields: list[str] | None) -> tuple[str, ...]: + """Normalize field names using aliases. + + Parameters + ---------- + fields : list[str] | None + Field names or aliases to normalize. If None, returns DEFAULT_FIELDS. + + Returns + ------- + tuple[str, ...] + Tuple of canonical field names. + + Raises + ------ + InvalidFieldError + If a field name is not recognized. + + Examples + -------- + >>> normalize_fields(None) + ('name', 'session_name', 'path') + + >>> normalize_fields(["s", "n"]) + ('session_name', 'name') + + >>> normalize_fields(["session_name", "path"]) + ('session_name', 'path') + + >>> normalize_fields(["invalid"]) + Traceback (most recent call last): + ... + tmuxp.cli.search.InvalidFieldError: Unknown search field: 'invalid'. ... + """ + if fields is None: + return DEFAULT_FIELDS + + result: list[str] = [] + for field in fields: + field_lower = field.lower() + if field_lower not in FIELD_ALIASES: + raise InvalidFieldError(field) + canonical = FIELD_ALIASES[field_lower] + if canonical not in result: + result.append(canonical) + + return tuple(result) + + +def _parse_field_prefix(term: str) -> tuple[str | None, str]: + """Extract field prefix from a search term. + + Parameters + ---------- + term : str + Search term, possibly with field prefix (e.g., "name:dev"). + + Returns + ------- + tuple[str | None, str] + Tuple of (field_prefix, pattern). field_prefix is None if no prefix. + + Examples + -------- + >>> _parse_field_prefix("name:dev") + ('name', 'dev') + + >>> _parse_field_prefix("s:myproject") + ('s', 'myproject') + + >>> _parse_field_prefix("development") + (None, 'development') + + >>> _parse_field_prefix("path:/home/user") + ('path', '/home/user') + + >>> _parse_field_prefix("window:") + ('window', '') + """ + if ":" not in term: + return None, term + + # Split on first colon only + prefix, _, pattern = term.partition(":") + prefix_lower = prefix.lower() + + # Check if prefix is a valid field alias + if prefix_lower in FIELD_ALIASES: + return prefix, pattern + + # Not a valid field prefix, treat entire term as pattern + return None, term + + +def parse_query_terms( + terms: list[str], + *, + default_fields: tuple[str, ...] = DEFAULT_FIELDS, +) -> list[SearchToken]: + """Parse query terms into search tokens. + + Each term can optionally have a field prefix (e.g., "name:dev"). + Terms without prefixes search the default fields. + + Parameters + ---------- + terms : list[str] + Query terms to parse. + default_fields : tuple[str, ...] + Fields to search when no prefix is specified. + + Returns + ------- + list[SearchToken] + List of parsed search tokens. + + Raises + ------ + InvalidFieldError + If a field prefix is not recognized. + + Examples + -------- + >>> tokens = parse_query_terms(["dev"]) + >>> tokens[0].fields + ('name', 'session_name', 'path') + >>> tokens[0].pattern + 'dev' + + >>> tokens = parse_query_terms(["name:dev", "s:prod"]) + >>> tokens[0] + SearchToken(fields=('name',), pattern='dev') + >>> tokens[1] + SearchToken(fields=('session_name',), pattern='prod') + + >>> tokens = parse_query_terms(["window:editor", "shell"]) + >>> tokens[0].fields + ('window',) + >>> tokens[1].fields + ('name', 'session_name', 'path') + + Unknown prefixes are treated as literal patterns (allows URLs, etc.): + + >>> tokens = parse_query_terms(["http://example.com"]) + >>> tokens[0].pattern + 'http://example.com' + >>> tokens[0].fields # Searches default fields + ('name', 'session_name', 'path') + """ + result: list[SearchToken] = [] + + for term in terms: + if not term: + continue + + prefix, pattern = _parse_field_prefix(term) + + # Validate and resolve field prefix, or use defaults + fields = normalize_fields([prefix]) if prefix is not None else default_fields + + if pattern: # Skip empty patterns + result.append(SearchToken(fields=fields, pattern=pattern)) + + return result + + +def _has_uppercase(pattern: str) -> bool: + """Check if pattern contains uppercase letters. + + Used for smart-case detection. + + Parameters + ---------- + pattern : str + Pattern to check. + + Returns + ------- + bool + True if pattern contains at least one uppercase letter. + + Examples + -------- + >>> _has_uppercase("dev") + False + + >>> _has_uppercase("Dev") + True + + >>> _has_uppercase("DEV") + True + + >>> _has_uppercase("123") + False + + >>> _has_uppercase("") + False + """ + return any(c.isupper() for c in pattern) + + +def compile_search_patterns( + tokens: list[SearchToken], + *, + ignore_case: bool = False, + smart_case: bool = False, + fixed_strings: bool = False, + word_regexp: bool = False, +) -> list[SearchPattern]: + """Compile search tokens into regex patterns. + + Parameters + ---------- + tokens : list[SearchToken] + Parsed search tokens to compile. + ignore_case : bool + If True, always ignore case. Default False. + smart_case : bool + If True, ignore case unless pattern has uppercase. Default False. + fixed_strings : bool + If True, treat patterns as literal strings, not regex. Default False. + word_regexp : bool + If True, match whole words only. Default False. + + Returns + ------- + list[SearchPattern] + List of compiled search patterns. + + Raises + ------ + re.error + If a pattern is invalid regex (when fixed_strings=False). + + Examples + -------- + Basic compilation: + + >>> tokens = [SearchToken(fields=("name",), pattern="dev")] + >>> patterns = compile_search_patterns(tokens) + >>> patterns[0].raw + 'dev' + >>> bool(patterns[0].regex.search("development")) + True + + Case-insensitive matching: + + >>> tokens = [SearchToken(fields=("name",), pattern="DEV")] + >>> patterns = compile_search_patterns(tokens, ignore_case=True) + >>> bool(patterns[0].regex.search("development")) + True + + Smart-case (uppercase = case-sensitive): + + >>> tokens = [SearchToken(fields=("name",), pattern="Dev")] + >>> patterns = compile_search_patterns(tokens, smart_case=True) + >>> bool(patterns[0].regex.search("Developer")) + True + >>> bool(patterns[0].regex.search("developer")) + False + + Smart-case (lowercase = case-insensitive): + + >>> tokens = [SearchToken(fields=("name",), pattern="dev")] + >>> patterns = compile_search_patterns(tokens, smart_case=True) + >>> bool(patterns[0].regex.search("DEVELOPMENT")) + True + + Fixed strings (escape regex metacharacters): + + >>> tokens = [SearchToken(fields=("name",), pattern="dev.*")] + >>> patterns = compile_search_patterns(tokens, fixed_strings=True) + >>> bool(patterns[0].regex.search("dev.*project")) + True + >>> bool(patterns[0].regex.search("development")) + False + + Word boundaries: + + >>> tokens = [SearchToken(fields=("name",), pattern="dev")] + >>> patterns = compile_search_patterns(tokens, word_regexp=True) + >>> bool(patterns[0].regex.search("my dev project")) + True + >>> bool(patterns[0].regex.search("development")) + False + """ + result: list[SearchPattern] = [] + + for token in tokens: + pattern_str = token.pattern + + # Escape for literal matching if requested + if fixed_strings: + pattern_str = re.escape(pattern_str) + + # Add word boundaries if requested + if word_regexp: + pattern_str = rf"\b{pattern_str}\b" + + # Determine case sensitivity + flags = 0 + if ignore_case or (smart_case and not _has_uppercase(token.pattern)): + flags |= re.IGNORECASE + + compiled = re.compile(pattern_str, flags) + result.append( + SearchPattern( + fields=token.fields, + raw=token.pattern, + regex=compiled, + ) + ) + + return result From b05957a0d8bfb3162c22911980cffbf1f2a16ff8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Jan 2026 10:19:29 -0600 Subject: [PATCH 40/99] feat(cli[search]): Add workspace field extraction and match evaluation Add workspace search functionality: - WorkspaceFields TypedDict: name, path, session_name, windows, panes - WorkspaceSearchResult: filepath, source, fields, matches for output - extract_workspace_fields: parses config for searchable content - evaluate_match: AND/OR pattern matching with match tracking - find_search_matches: coordinates search across workspace list Match results include actual matched text for highlighting support. --- src/tmuxp/cli/search.py | 408 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 408 insertions(+) diff --git a/src/tmuxp/cli/search.py b/src/tmuxp/cli/search.py index 959485675b..fe0a4637d5 100644 --- a/src/tmuxp/cli/search.py +++ b/src/tmuxp/cli/search.py @@ -23,9 +23,13 @@ from __future__ import annotations +import pathlib import re import typing as t +from tmuxp._internal.config_reader import ConfigReader +from tmuxp._internal.private_path import PrivatePath + if t.TYPE_CHECKING: pass @@ -437,3 +441,407 @@ def compile_search_patterns( ) return result + + +class WorkspaceFields(t.TypedDict): + """Extracted searchable fields from a workspace file. + + Attributes + ---------- + name : str + Workspace name (file stem without extension). + path : str + Path to workspace file (with ~ contraction). + session_name : str + Session name from config, or empty string if not found. + windows : list[str] + List of window names from config. + panes : list[str] + List of pane commands/shell_commands from config. + + Examples + -------- + >>> fields: WorkspaceFields = { + ... "name": "dev", + ... "path": "~/.tmuxp/dev.yaml", + ... "session_name": "development", + ... "windows": ["editor", "shell"], + ... "panes": ["vim", "git status"], + ... } + >>> fields["name"] + 'dev' + """ + + name: str + path: str + session_name: str + windows: list[str] + panes: list[str] + + +class WorkspaceSearchResult(t.TypedDict): + """Search result for a workspace that matched. + + Attributes + ---------- + filepath : str + Absolute path to the workspace file. + source : str + Source location: "local" or "global". + fields : WorkspaceFields + Extracted searchable fields. + matches : dict[str, list[str]] + Mapping of field name to matched strings for highlighting. + + Examples + -------- + >>> result: WorkspaceSearchResult = { + ... "filepath": "/home/user/.tmuxp/dev.yaml", + ... "source": "global", + ... "fields": { + ... "name": "dev", + ... "path": "~/.tmuxp/dev.yaml", + ... "session_name": "development", + ... "windows": ["editor"], + ... "panes": [], + ... }, + ... "matches": {"name": ["dev"]}, + ... } + >>> result["source"] + 'global' + """ + + filepath: str + source: str + fields: WorkspaceFields + matches: dict[str, list[str]] + + +def extract_workspace_fields(filepath: pathlib.Path) -> WorkspaceFields: + """Extract searchable fields from a workspace file. + + Parses the workspace configuration and extracts name, path, session_name, + window names, and pane commands for searching. + + Parameters + ---------- + filepath : pathlib.Path + Path to the workspace file. + + Returns + ------- + WorkspaceFields + Dictionary of extracted fields. + + Examples + -------- + >>> import tempfile + >>> import pathlib + >>> content = ''' + ... session_name: my-project + ... windows: + ... - window_name: editor + ... panes: + ... - vim + ... - shell_command: git status + ... - window_name: shell + ... ''' + >>> with tempfile.NamedTemporaryFile( + ... suffix='.yaml', delete=False, mode='w' + ... ) as f: + ... _ = f.write(content) + ... temp_path = pathlib.Path(f.name) + >>> fields = extract_workspace_fields(temp_path) + >>> fields["session_name"] + 'my-project' + >>> sorted(fields["windows"]) + ['editor', 'shell'] + >>> 'vim' in fields["panes"] + True + >>> temp_path.unlink() + """ + # Basic fields from file + name = filepath.stem + path = str(PrivatePath(filepath)) + + # Try to parse config for session_name, windows, panes + session_name = "" + windows: list[str] = [] + panes: list[str] = [] + + try: + config = ConfigReader.from_file(filepath) + if isinstance(config.content, dict): + session_name = str(config.content.get("session_name", "")) + + # Extract window names and pane commands + for window in config.content.get("windows", []): + if not isinstance(window, dict): + continue + + # Window name + if window_name := window.get("window_name"): + windows.append(str(window_name)) + + # Pane commands + for pane in window.get("panes", []): + if isinstance(pane, str): + panes.append(pane) + elif isinstance(pane, dict): + # shell_command can be str or list + cmds = pane.get("shell_command", []) + if isinstance(cmds, str): + panes.append(cmds) + elif isinstance(cmds, list): + panes.extend(str(cmd) for cmd in cmds if cmd) + except Exception: + # If config parsing fails, continue with empty content fields + pass + + return WorkspaceFields( + name=name, + path=path, + session_name=session_name, + windows=windows, + panes=panes, + ) + + +def _get_field_values(fields: WorkspaceFields, field_name: str) -> list[str]: + """Get values for a field, normalizing to list. + + Parameters + ---------- + fields : WorkspaceFields + Extracted workspace fields. + field_name : str + Canonical field name to retrieve. + + Returns + ------- + list[str] + List of values for the field. + + Examples + -------- + >>> fields: WorkspaceFields = { + ... "name": "dev", + ... "path": "~/.tmuxp/dev.yaml", + ... "session_name": "development", + ... "windows": ["editor", "shell"], + ... "panes": ["vim"], + ... } + >>> _get_field_values(fields, "name") + ['dev'] + >>> _get_field_values(fields, "windows") + ['editor', 'shell'] + >>> _get_field_values(fields, "window") + ['editor', 'shell'] + """ + # Handle field name aliasing (window -> windows, pane -> panes) + if field_name == "window": + field_name = "windows" + elif field_name == "pane": + field_name = "panes" + + # Access fields directly for type safety + if field_name == "name": + return [fields["name"]] if fields["name"] else [] + if field_name == "path": + return [fields["path"]] if fields["path"] else [] + if field_name == "session_name": + return [fields["session_name"]] if fields["session_name"] else [] + if field_name == "windows": + return fields["windows"] + if field_name == "panes": + return fields["panes"] + + return [] + + +def evaluate_match( + fields: WorkspaceFields, + patterns: list[SearchPattern], + *, + match_any: bool = False, +) -> tuple[bool, dict[str, list[str]]]: + """Evaluate if workspace fields match search patterns. + + Parameters + ---------- + fields : WorkspaceFields + Extracted workspace fields to search. + patterns : list[SearchPattern] + Compiled search patterns. + match_any : bool + If True, match if ANY pattern matches (OR logic). + If False, ALL patterns must match (AND logic). Default False. + + Returns + ------- + tuple[bool, dict[str, list[str]]] + Tuple of (matched, {field_name: [matched_strings]}). + The matches dict contains actual matched text for highlighting. + + Examples + -------- + >>> import re + >>> fields: WorkspaceFields = { + ... "name": "dev-project", + ... "path": "~/.tmuxp/dev-project.yaml", + ... "session_name": "development", + ... "windows": ["editor", "shell"], + ... "panes": ["vim", "git status"], + ... } + + Single pattern match: + + >>> pattern = SearchPattern( + ... fields=("name",), + ... raw="dev", + ... regex=re.compile("dev"), + ... ) + >>> matched, matches = evaluate_match(fields, [pattern]) + >>> matched + True + >>> "name" in matches + True + + AND logic (default) - all patterns must match: + + >>> p1 = SearchPattern(fields=("name",), raw="dev", regex=re.compile("dev")) + >>> p2 = SearchPattern(fields=("name",), raw="xyz", regex=re.compile("xyz")) + >>> matched, _ = evaluate_match(fields, [p1, p2], match_any=False) + >>> matched + False + + OR logic - any pattern can match: + + >>> matched, _ = evaluate_match(fields, [p1, p2], match_any=True) + >>> matched + True + + Window field search: + + >>> p_win = SearchPattern( + ... fields=("window",), + ... raw="editor", + ... regex=re.compile("editor"), + ... ) + >>> matched, matches = evaluate_match(fields, [p_win]) + >>> matched + True + >>> "window" in matches + True + """ + all_matches: dict[str, list[str]] = {} + pattern_results: list[bool] = [] + + for pattern in patterns: + pattern_matched = False + + for field_name in pattern.fields: + values = _get_field_values(fields, field_name) + + for value in values: + if match := pattern.regex.search(value): + pattern_matched = True + # Store matched text for highlighting + if field_name not in all_matches: + all_matches[field_name] = [] + all_matches[field_name].append(match.group()) + + pattern_results.append(pattern_matched) + + # Apply match logic + if match_any: + final_matched = any(pattern_results) + else: + final_matched = all(pattern_results) if pattern_results else False + + return final_matched, all_matches + + +def find_search_matches( + workspaces: list[tuple[pathlib.Path, str]], + patterns: list[SearchPattern], + *, + match_any: bool = False, + invert_match: bool = False, +) -> list[WorkspaceSearchResult]: + """Find workspaces matching search patterns. + + Parameters + ---------- + workspaces : list[tuple[pathlib.Path, str]] + List of (filepath, source) tuples to search. Source is "local" or "global". + patterns : list[SearchPattern] + Compiled search patterns. + match_any : bool + If True, match if ANY pattern matches (OR logic). Default False (AND). + invert_match : bool + If True, return workspaces that do NOT match. Default False. + + Returns + ------- + list[WorkspaceSearchResult] + List of matching workspace results with match information. + + Examples + -------- + >>> import tempfile + >>> import pathlib + >>> import re + >>> content = "session_name: dev-session" + chr(10) + "windows: []" + >>> with tempfile.NamedTemporaryFile( + ... suffix='.yaml', delete=False, mode='w' + ... ) as f: + ... _ = f.write(content) + ... temp_path = pathlib.Path(f.name) + + >>> pattern = SearchPattern( + ... fields=("session_name",), + ... raw="dev", + ... regex=re.compile("dev"), + ... ) + >>> results = find_search_matches([(temp_path, "global")], [pattern]) + >>> len(results) + 1 + >>> results[0]["source"] + 'global' + + Invert match returns non-matching workspaces: + + >>> pattern_nomatch = SearchPattern( + ... fields=("name",), + ... raw="nonexistent", + ... regex=re.compile("nonexistent"), + ... ) + >>> results = find_search_matches( + ... [(temp_path, "global")], [pattern_nomatch], invert_match=True + ... ) + >>> len(results) + 1 + >>> temp_path.unlink() + """ + results: list[WorkspaceSearchResult] = [] + + for filepath, source in workspaces: + fields = extract_workspace_fields(filepath) + matched, matches = evaluate_match(fields, patterns, match_any=match_any) + + # Apply invert logic + if invert_match: + matched = not matched + + if matched: + results.append( + WorkspaceSearchResult( + filepath=str(filepath), + source=source, + fields=fields, + matches=matches, + ) + ) + + return results From 38039185f25eeb1d6f98cde048089d3cc96e5348 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Jan 2026 10:21:08 -0600 Subject: [PATCH 41/99] feat(cli[search]): Add output formatting with match highlighting Add search result output functionality: - highlight_matches: regex-based highlighting with span merging - _output_search_results: grouped output (local/global) with colors - JSON output includes matched_fields and matches for scripting - Human output shows session_name, windows, panes when matched --- src/tmuxp/cli/search.py | 172 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) diff --git a/src/tmuxp/cli/search.py b/src/tmuxp/cli/search.py index fe0a4637d5..f21026bff9 100644 --- a/src/tmuxp/cli/search.py +++ b/src/tmuxp/cli/search.py @@ -30,6 +30,9 @@ from tmuxp._internal.config_reader import ConfigReader from tmuxp._internal.private_path import PrivatePath +from ._colors import Colors +from ._output import OutputFormatter + if t.TYPE_CHECKING: pass @@ -845,3 +848,172 @@ def find_search_matches( ) return results + + +def highlight_matches( + text: str, + patterns: list[SearchPattern], + *, + colors: Colors, +) -> str: + """Highlight regex matches in text. + + Parameters + ---------- + text : str + Text to search and highlight. + patterns : list[SearchPattern] + Compiled search patterns (uses their regex attribute). + colors : Colors + Color manager for highlighting. + + Returns + ------- + str + Text with matches highlighted, or original text if no matches. + + Examples + -------- + >>> from tmuxp.cli._colors import ColorMode, Colors + >>> colors = Colors(ColorMode.NEVER) + >>> pattern = SearchPattern( + ... fields=("name",), + ... raw="dev", + ... regex=re.compile("dev"), + ... ) + >>> highlight_matches("development", [pattern], colors=colors) + 'development' + + With colors enabled (ALWAYS mode): + + >>> colors_on = Colors(ColorMode.ALWAYS) + >>> result = highlight_matches("development", [pattern], colors=colors_on) + >>> "dev" in result + True + >>> chr(27) in result # Contains ANSI escape + True + """ + if not patterns: + return text + + # Collect all match spans + spans: list[tuple[int, int]] = [] + for pattern in patterns: + spans.extend((m.start(), m.end()) for m in pattern.regex.finditer(text)) + + if not spans: + return text + + # Sort and merge overlapping spans + spans.sort() + merged: list[tuple[int, int]] = [] + for start, end in spans: + if merged and start <= merged[-1][1]: + # Overlapping or adjacent, extend previous + merged[-1] = (merged[-1][0], max(merged[-1][1], end)) + else: + merged.append((start, end)) + + # Build result with highlights + result: list[str] = [] + pos = 0 + for start, end in merged: + # Add non-matching text before this match + if pos < start: + result.append(text[pos:start]) + # Add highlighted match + result.append(colors.highlight(text[start:end])) + pos = end + + # Add any remaining text after last match + if pos < len(text): + result.append(text[pos:]) + + return "".join(result) + + +def _output_search_results( + results: list[WorkspaceSearchResult], + patterns: list[SearchPattern], + formatter: OutputFormatter, + colors: Colors, +) -> None: + """Output search results in human-readable or JSON format. + + Parameters + ---------- + results : list[WorkspaceSearchResult] + Search results to output. + patterns : list[SearchPattern] + Patterns used for highlighting. + formatter : OutputFormatter + Output formatter for JSON/NDJSON/human modes. + colors : Colors + Color manager. + """ + if not results: + formatter.emit_text(colors.warning("No matching workspaces found.")) + return + + # Group by source for human output + local_results = [r for r in results if r["source"] == "local"] + global_results = [r for r in results if r["source"] == "global"] + + def output_result(result: WorkspaceSearchResult, show_path: bool) -> None: + """Output a single search result.""" + fields = result["fields"] + + # JSON/NDJSON output: emit structured data + json_data = { + "name": fields["name"], + "path": fields["path"], + "session_name": fields["session_name"], + "source": result["source"], + "matched_fields": list(result["matches"].keys()), + "matches": result["matches"], + } + formatter.emit(json_data) + + # Human output: formatted text with highlighting + name_display = highlight_matches(fields["name"], patterns, colors=colors) + path_info = f" {colors.muted(fields['path'])}" if show_path else "" + formatter.emit_text(f" {colors.info(name_display)}{path_info}") + + # Show matched session_name if different from name + session_name = fields["session_name"] + if session_name and session_name != fields["name"]: + session_display = highlight_matches(session_name, patterns, colors=colors) + formatter.emit_text(f" session: {session_display}") + + # Show matched windows + if result["matches"].get("window"): + window_names = [ + highlight_matches(w, patterns, colors=colors) for w in fields["windows"] + ] + if window_names: + formatter.emit_text(f" windows: {', '.join(window_names)}") + + # Show matched panes + if result["matches"].get("pane"): + pane_cmds = fields["panes"][:3] # Limit to first 3 + pane_displays = [ + highlight_matches(p, patterns, colors=colors) for p in pane_cmds + ] + if len(fields["panes"]) > 3: + pane_displays.append("...") + if pane_displays: + formatter.emit_text(f" panes: {', '.join(pane_displays)}") + + # Output local results first + if local_results: + formatter.emit_text(colors.muted("Local workspaces:")) + for result in local_results: + output_result(result, show_path=True) + + # Output global results + if global_results: + if local_results: + formatter.emit_text("") # Blank line separator + formatter.emit_text(colors.muted("Global workspaces:")) + for result in global_results: + output_result(result, show_path=False) From 0876037faf541411c631330ae8990e0bdb97a092 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Jan 2026 10:24:16 -0600 Subject: [PATCH 42/99] feat(cli[search]): Add CLI integration and subparser Complete CLI integration for tmuxp search command: - SEARCH_DESCRIPTION with usage examples - CLISearchNamespace for typed argument parsing - create_search_subparser() with all search options - command_search() entrypoint connecting all components - Register in __init__.py with routing - Add search to main CLI description examples Supports: -i/-S (case), -F (fixed), -w (word), -v (invert), --any (OR logic), --json/--ndjson output modes. --- src/tmuxp/cli/__init__.py | 27 ++++ src/tmuxp/cli/search.py | 281 +++++++++++++++++++++++++++++++++++++- 2 files changed, 305 insertions(+), 3 deletions(-) diff --git a/src/tmuxp/cli/__init__.py b/src/tmuxp/cli/__init__.py index 969ecdbd05..6f11994e67 100644 --- a/src/tmuxp/cli/__init__.py +++ b/src/tmuxp/cli/__init__.py @@ -45,6 +45,12 @@ create_load_subparser, ) from .ls import LS_DESCRIPTION, CLILsNamespace, command_ls, create_ls_subparser +from .search import ( + SEARCH_DESCRIPTION, + CLISearchNamespace, + command_search, + create_search_subparser, +) from .shell import ( SHELL_DESCRIPTION, CLIShellNamespace, @@ -84,6 +90,13 @@ "tmuxp ls", ], ), + ( + "search", + [ + "tmuxp search dev", + "tmuxp search name:myproject", + ], + ), ( "shell", [ @@ -134,6 +147,7 @@ "convert", "edit", "import", + "search", "shell", "debug-info", ] @@ -214,6 +228,14 @@ def create_parser() -> argparse.ArgumentParser: ) create_ls_subparser(ls_parser) + search_parser = subparsers.add_parser( + "search", + help="search workspace files by name, session, path, or content", + description=SEARCH_DESCRIPTION, + formatter_class=TmuxpHelpFormatter, + ) + create_search_subparser(search_parser) + edit_parser = subparsers.add_parser( "edit", help="run $EDITOR on workspace file", @@ -326,6 +348,11 @@ def cli(_args: list[str] | None = None) -> None: args=CLILsNamespace(**vars(args)), parser=parser, ) + elif args.subparser_name == "search": + command_search( + args=CLISearchNamespace(**vars(args)), + parser=parser, + ) def startup(config_dir: pathlib.Path) -> None: diff --git a/src/tmuxp/cli/search.py b/src/tmuxp/cli/search.py index f21026bff9..6a258e8c21 100644 --- a/src/tmuxp/cli/search.py +++ b/src/tmuxp/cli/search.py @@ -23,18 +23,23 @@ from __future__ import annotations +import argparse import pathlib import re import typing as t from tmuxp._internal.config_reader import ConfigReader from tmuxp._internal.private_path import PrivatePath +from tmuxp.workspace.constants import VALID_WORKSPACE_DIR_FILE_EXTENSIONS +from tmuxp.workspace.finders import find_local_workspace_files, get_workspace_dir -from ._colors import Colors -from ._output import OutputFormatter +from ._colors import Colors, build_description, get_color_mode +from ._output import OutputFormatter, get_output_mode if t.TYPE_CHECKING: - pass + from typing import TypeAlias + + CLIColorModeLiteral: TypeAlias = t.Literal["auto", "always", "never"] #: Field name aliases for search queries FIELD_ALIASES: dict[str, str] = { @@ -1017,3 +1022,273 @@ def output_result(result: WorkspaceSearchResult, show_path: bool) -> None: formatter.emit_text(colors.muted("Global workspaces:")) for result in global_results: output_result(result, show_path=False) + + +SEARCH_DESCRIPTION = build_description( + """ + Search workspace files by name, session, path, window, or pane content. + """, + ( + ( + None, + [ + "tmuxp search dev", + 'tmuxp search "my.*project"', + "tmuxp search name:dev", + "tmuxp search s:development", + ], + ), + ( + "Field-scoped search:", + [ + "tmuxp search window:editor", + "tmuxp search pane:vim", + "tmuxp search p:~/.tmuxp", + ], + ), + ( + "Matching options:", + [ + "tmuxp search -i DEV", + "tmuxp search -S DevProject", + "tmuxp search -F 'my.project'", + "tmuxp search --word-regexp test", + ], + ), + ( + "Multiple patterns:", + [ + "tmuxp search dev production", + "tmuxp search --any dev production", + "tmuxp search -v staging", + ], + ), + ( + "Machine-readable output:", + [ + "tmuxp search --json dev", + "tmuxp search --ndjson dev | jq '.name'", + ], + ), + ), +) + + +class CLISearchNamespace(argparse.Namespace): + """Typed :class:`argparse.Namespace` for tmuxp search command. + + Examples + -------- + >>> ns = CLISearchNamespace() + >>> ns.query_terms = ["dev"] + >>> ns.query_terms + ['dev'] + """ + + color: CLIColorModeLiteral + query_terms: list[str] + field: list[str] | None + ignore_case: bool + smart_case: bool + fixed_strings: bool + word_regexp: bool + invert_match: bool + match_any: bool + output_json: bool + output_ndjson: bool + + +def create_search_subparser( + parser: argparse.ArgumentParser, +) -> argparse.ArgumentParser: + """Augment :class:`argparse.ArgumentParser` with ``search`` subcommand. + + Parameters + ---------- + parser : argparse.ArgumentParser + The parser to augment. + + Returns + ------- + argparse.ArgumentParser + The augmented parser. + + Examples + -------- + >>> import argparse + >>> parser = argparse.ArgumentParser() + >>> result = create_search_subparser(parser) + >>> result is parser + True + """ + # Positional arguments + parser.add_argument( + "query_terms", + nargs="*", + metavar="PATTERN", + help="search patterns (prefix with field: for field-scoped search)", + ) + + # Field restriction + parser.add_argument( + "-f", + "--field", + action="append", + metavar="FIELD", + help="restrict search to field(s): name, session/s, path/p, window/w, pane", + ) + + # Matching options + parser.add_argument( + "-i", + "--ignore-case", + action="store_true", + help="case-insensitive matching", + ) + parser.add_argument( + "-S", + "--smart-case", + action="store_true", + help="case-insensitive unless pattern has uppercase", + ) + parser.add_argument( + "-F", + "--fixed-strings", + action="store_true", + help="treat patterns as literal strings, not regex", + ) + parser.add_argument( + "-w", + "--word-regexp", + action="store_true", + help="match whole words only", + ) + parser.add_argument( + "-v", + "--invert-match", + action="store_true", + help="show workspaces that do NOT match", + ) + parser.add_argument( + "--any", + dest="match_any", + action="store_true", + help="match ANY pattern (OR logic); default is ALL (AND logic)", + ) + + # Output format + parser.add_argument( + "--json", + action="store_true", + dest="output_json", + help="output as JSON", + ) + parser.add_argument( + "--ndjson", + action="store_true", + dest="output_ndjson", + help="output as NDJSON (one JSON per line)", + ) + + return parser + + +def command_search( + args: CLISearchNamespace | None = None, + parser: argparse.ArgumentParser | None = None, +) -> None: + """Entrypoint for ``tmuxp search`` subcommand. + + Searches workspace files in local (cwd and parents) and global (~/.tmuxp/) + directories. + + Parameters + ---------- + args : CLISearchNamespace | None + Parsed command-line arguments. + parser : argparse.ArgumentParser | None + The argument parser (unused but required by CLI interface). + + Examples + -------- + >>> # command_search() searches workspaces with given patterns + """ + # Get color mode from args or default to AUTO + color_mode = get_color_mode(args.color if args else None) + colors = Colors(color_mode) + + # Determine output mode + output_json = args.output_json if args else False + output_ndjson = args.output_ndjson if args else False + output_mode = get_output_mode(output_json, output_ndjson) + formatter = OutputFormatter(output_mode) + + # Get query terms + query_terms = args.query_terms if args else [] + + if not query_terms: + formatter.emit_text(colors.warning("No search pattern provided.")) + formatter.emit_text(colors.muted("Usage: tmuxp search PATTERN [PATTERN ...]")) + formatter.finalize() + return + + # Parse and compile patterns + try: + # Get default fields (possibly restricted by --field) + default_fields = normalize_fields(args.field if args else None) + tokens = parse_query_terms(query_terms, default_fields=default_fields) + + if not tokens: + formatter.emit_text(colors.warning("No valid search patterns.")) + formatter.finalize() + return + + patterns = compile_search_patterns( + tokens, + ignore_case=args.ignore_case if args else False, + smart_case=args.smart_case if args else False, + fixed_strings=args.fixed_strings if args else False, + word_regexp=args.word_regexp if args else False, + ) + except InvalidFieldError as e: + formatter.emit_text(colors.error(str(e))) + formatter.finalize() + return + except re.error as e: + formatter.emit_text(colors.error(f"Invalid regex pattern: {e}")) + formatter.finalize() + return + + # Collect workspaces: local (cwd + parents) + global (~/.tmuxp/) + workspaces: list[tuple[pathlib.Path, str]] = [] + + # Local workspace files + local_files = find_local_workspace_files() + workspaces.extend((f, "local") for f in local_files) + + # Global workspace files + tmuxp_dir = pathlib.Path(get_workspace_dir()) + if tmuxp_dir.exists() and tmuxp_dir.is_dir(): + workspaces.extend( + (f, "global") + for f in sorted(tmuxp_dir.iterdir()) + if not f.is_dir() + and f.suffix.lower() in VALID_WORKSPACE_DIR_FILE_EXTENSIONS + ) + + if not workspaces: + formatter.emit_text(colors.warning("No workspaces found.")) + formatter.finalize() + return + + # Find matches + results = find_search_matches( + workspaces, + patterns, + match_any=args.match_any if args else False, + invert_match=args.invert_match if args else False, + ) + + # Output results + _output_search_results(results, patterns, formatter, colors) + formatter.finalize() From 43b7d039a7132b5ca8c06484468b2ebb91e3347d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Jan 2026 10:27:25 -0600 Subject: [PATCH 43/99] test(cli[search]): Add comprehensive tests for search command Add 59 tests covering all search functionality: - normalize_fields: field aliases, case handling, validation - parse_query_terms: prefixes, URLs, empty patterns - compile_search_patterns: all matching modes (case, word, fixed, regex) - extract_workspace_fields: config parsing, panes, windows - evaluate_match: AND/OR logic, field searches - find_search_matches: basic, invert, multiple workspaces - highlight_matches: colors, multiple matches - CLI subparser: options, output formats - Output formatting: JSON, NDJSON, human readable --- tests/cli/test_search.py | 838 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 838 insertions(+) create mode 100644 tests/cli/test_search.py diff --git a/tests/cli/test_search.py b/tests/cli/test_search.py new file mode 100644 index 0000000000..6e12eab7ab --- /dev/null +++ b/tests/cli/test_search.py @@ -0,0 +1,838 @@ +"""CLI tests for tmuxp search command.""" + +from __future__ import annotations + +import json +import pathlib +import re +import typing as t + +import pytest + +from tmuxp.cli._colors import ColorMode, Colors +from tmuxp.cli._output import OutputFormatter, OutputMode +from tmuxp.cli.search import ( + DEFAULT_FIELDS, + InvalidFieldError, + SearchPattern, + SearchToken, + WorkspaceFields, + _get_field_values, + _output_search_results, + compile_search_patterns, + create_search_subparser, + evaluate_match, + extract_workspace_fields, + find_search_matches, + highlight_matches, + normalize_fields, + parse_query_terms, +) + + +class NormalizeFieldsFixture(t.NamedTuple): + """Test fixture for normalize_fields.""" + + test_id: str + fields: list[str] | None + expected: tuple[str, ...] + raises: type[Exception] | None + + +NORMALIZE_FIELDS_FIXTURES: list[NormalizeFieldsFixture] = [ + NormalizeFieldsFixture( + test_id="none_returns_defaults", + fields=None, + expected=DEFAULT_FIELDS, + raises=None, + ), + NormalizeFieldsFixture( + test_id="name_alias", + fields=["n"], + expected=("name",), + raises=None, + ), + NormalizeFieldsFixture( + test_id="session_aliases", + fields=["s", "session", "session_name"], + expected=("session_name",), + raises=None, + ), + NormalizeFieldsFixture( + test_id="path_alias", + fields=["p"], + expected=("path",), + raises=None, + ), + NormalizeFieldsFixture( + test_id="window_alias", + fields=["w"], + expected=("window",), + raises=None, + ), + NormalizeFieldsFixture( + test_id="multiple_fields", + fields=["name", "s", "window"], + expected=("name", "session_name", "window"), + raises=None, + ), + NormalizeFieldsFixture( + test_id="invalid_field", + fields=["invalid"], + expected=(), + raises=InvalidFieldError, + ), + NormalizeFieldsFixture( + test_id="case_insensitive", + fields=["NAME", "Session"], + expected=("name", "session_name"), + raises=None, + ), +] + + +@pytest.mark.parametrize( + NormalizeFieldsFixture._fields, + NORMALIZE_FIELDS_FIXTURES, + ids=[test.test_id for test in NORMALIZE_FIELDS_FIXTURES], +) +def test_normalize_fields( + test_id: str, + fields: list[str] | None, + expected: tuple[str, ...], + raises: type[Exception] | None, +) -> None: + """Test normalize_fields function.""" + if raises: + with pytest.raises(raises): + normalize_fields(fields) + else: + result = normalize_fields(fields) + assert result == expected + + +class ParseQueryTermsFixture(t.NamedTuple): + """Test fixture for parse_query_terms.""" + + test_id: str + terms: list[str] + expected_count: int + expected_first_fields: tuple[str, ...] | None + expected_first_pattern: str | None + + +PARSE_QUERY_TERMS_FIXTURES: list[ParseQueryTermsFixture] = [ + ParseQueryTermsFixture( + test_id="simple_term", + terms=["dev"], + expected_count=1, + expected_first_fields=DEFAULT_FIELDS, + expected_first_pattern="dev", + ), + ParseQueryTermsFixture( + test_id="name_prefix", + terms=["name:dev"], + expected_count=1, + expected_first_fields=("name",), + expected_first_pattern="dev", + ), + ParseQueryTermsFixture( + test_id="session_prefix", + terms=["s:production"], + expected_count=1, + expected_first_fields=("session_name",), + expected_first_pattern="production", + ), + ParseQueryTermsFixture( + test_id="multiple_terms", + terms=["dev", "production"], + expected_count=2, + expected_first_fields=DEFAULT_FIELDS, + expected_first_pattern="dev", + ), + ParseQueryTermsFixture( + test_id="url_not_field", + terms=["http://example.com"], + expected_count=1, + expected_first_fields=DEFAULT_FIELDS, + expected_first_pattern="http://example.com", + ), + ParseQueryTermsFixture( + test_id="empty_pattern_skipped", + terms=["name:"], + expected_count=0, + expected_first_fields=None, + expected_first_pattern=None, + ), + ParseQueryTermsFixture( + test_id="path_with_colons", + terms=["path:/home/user/project"], + expected_count=1, + expected_first_fields=("path",), + expected_first_pattern="/home/user/project", + ), +] + + +@pytest.mark.parametrize( + ParseQueryTermsFixture._fields, + PARSE_QUERY_TERMS_FIXTURES, + ids=[test.test_id for test in PARSE_QUERY_TERMS_FIXTURES], +) +def test_parse_query_terms( + test_id: str, + terms: list[str], + expected_count: int, + expected_first_fields: tuple[str, ...] | None, + expected_first_pattern: str | None, +) -> None: + """Test parse_query_terms function.""" + result = parse_query_terms(terms) + + assert len(result) == expected_count + + if expected_count > 0: + assert result[0].fields == expected_first_fields + assert result[0].pattern == expected_first_pattern + + +class CompileSearchPatternsFixture(t.NamedTuple): + """Test fixture for compile_search_patterns.""" + + test_id: str + pattern: str + ignore_case: bool + smart_case: bool + fixed_strings: bool + word_regexp: bool + test_string: str + should_match: bool + + +COMPILE_SEARCH_PATTERNS_FIXTURES: list[CompileSearchPatternsFixture] = [ + CompileSearchPatternsFixture( + test_id="basic_match", + pattern="dev", + ignore_case=False, + smart_case=False, + fixed_strings=False, + word_regexp=False, + test_string="development", + should_match=True, + ), + CompileSearchPatternsFixture( + test_id="case_sensitive_no_match", + pattern="DEV", + ignore_case=False, + smart_case=False, + fixed_strings=False, + word_regexp=False, + test_string="development", + should_match=False, + ), + CompileSearchPatternsFixture( + test_id="ignore_case_match", + pattern="DEV", + ignore_case=True, + smart_case=False, + fixed_strings=False, + word_regexp=False, + test_string="development", + should_match=True, + ), + CompileSearchPatternsFixture( + test_id="smart_case_lowercase", + pattern="dev", + ignore_case=False, + smart_case=True, + fixed_strings=False, + word_regexp=False, + test_string="DEVELOPMENT", + should_match=True, + ), + CompileSearchPatternsFixture( + test_id="smart_case_uppercase_no_match", + pattern="Dev", + ignore_case=False, + smart_case=True, + fixed_strings=False, + word_regexp=False, + test_string="development", + should_match=False, + ), + CompileSearchPatternsFixture( + test_id="fixed_strings_literal", + pattern="dev.*", + ignore_case=False, + smart_case=False, + fixed_strings=True, + word_regexp=False, + test_string="dev.*project", + should_match=True, + ), + CompileSearchPatternsFixture( + test_id="fixed_strings_no_regex", + pattern="dev.*", + ignore_case=False, + smart_case=False, + fixed_strings=True, + word_regexp=False, + test_string="development", + should_match=False, + ), + CompileSearchPatternsFixture( + test_id="word_boundary_match", + pattern="dev", + ignore_case=False, + smart_case=False, + fixed_strings=False, + word_regexp=True, + test_string="my dev project", + should_match=True, + ), + CompileSearchPatternsFixture( + test_id="word_boundary_no_match", + pattern="dev", + ignore_case=False, + smart_case=False, + fixed_strings=False, + word_regexp=True, + test_string="development", + should_match=False, + ), + CompileSearchPatternsFixture( + test_id="regex_pattern", + pattern="dev.*proj", + ignore_case=False, + smart_case=False, + fixed_strings=False, + word_regexp=False, + test_string="dev-project", + should_match=True, + ), +] + + +@pytest.mark.parametrize( + CompileSearchPatternsFixture._fields, + COMPILE_SEARCH_PATTERNS_FIXTURES, + ids=[test.test_id for test in COMPILE_SEARCH_PATTERNS_FIXTURES], +) +def test_compile_search_patterns( + test_id: str, + pattern: str, + ignore_case: bool, + smart_case: bool, + fixed_strings: bool, + word_regexp: bool, + test_string: str, + should_match: bool, +) -> None: + """Test compile_search_patterns function.""" + tokens = [SearchToken(fields=("name",), pattern=pattern)] + + patterns = compile_search_patterns( + tokens, + ignore_case=ignore_case, + smart_case=smart_case, + fixed_strings=fixed_strings, + word_regexp=word_regexp, + ) + + assert len(patterns) == 1 + match = patterns[0].regex.search(test_string) + assert bool(match) == should_match + + +class TestExtractWorkspaceFields: + """Tests for extract_workspace_fields.""" + + def test_basic_extraction(self, tmp_path: pathlib.Path) -> None: + """Extract fields from basic workspace file.""" + workspace = tmp_path / "test.yaml" + workspace.write_text( + "session_name: my-session\n" + "windows:\n" + " - window_name: editor\n" + " panes:\n" + " - vim\n" + " - window_name: shell\n" + ) + + fields = extract_workspace_fields(workspace) + + assert fields["name"] == "test" + assert fields["session_name"] == "my-session" + assert "editor" in fields["windows"] + assert "shell" in fields["windows"] + assert "vim" in fields["panes"] + + def test_pane_shell_command_dict(self, tmp_path: pathlib.Path) -> None: + """Extract pane commands from dict format.""" + workspace = tmp_path / "test.yaml" + workspace.write_text( + "session_name: test\n" + "windows:\n" + " - window_name: main\n" + " panes:\n" + " - shell_command: git status\n" + " - shell_command:\n" + " - npm install\n" + " - npm start\n" + ) + + fields = extract_workspace_fields(workspace) + + assert "git status" in fields["panes"] + assert "npm install" in fields["panes"] + assert "npm start" in fields["panes"] + + def test_missing_session_name(self, tmp_path: pathlib.Path) -> None: + """Handle workspace without session_name.""" + workspace = tmp_path / "test.yaml" + workspace.write_text("windows:\n - window_name: main\n") + + fields = extract_workspace_fields(workspace) + + assert fields["session_name"] == "" + assert fields["name"] == "test" + + def test_invalid_yaml(self, tmp_path: pathlib.Path) -> None: + """Handle invalid YAML gracefully.""" + workspace = tmp_path / "test.yaml" + workspace.write_text("{{{{invalid yaml") + + fields = extract_workspace_fields(workspace) + + assert fields["name"] == "test" + assert fields["session_name"] == "" + assert fields["windows"] == [] + + def test_path_uses_privacy( + self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Path should use PrivatePath for home contraction.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: tmp_path) + workspace = tmp_path / "test.yaml" + workspace.write_text("session_name: test\n") + + fields = extract_workspace_fields(workspace) + + assert fields["path"] == "~/test.yaml" + + +class TestEvaluateMatch: + """Tests for evaluate_match function.""" + + @pytest.fixture() + def sample_fields(self) -> WorkspaceFields: + """Sample workspace fields for testing.""" + return WorkspaceFields( + name="dev-project", + path="~/.tmuxp/dev-project.yaml", + session_name="development", + windows=["editor", "shell", "logs"], + panes=["vim", "git status", "tail -f"], + ) + + def test_single_pattern_match(self, sample_fields: WorkspaceFields) -> None: + """Single pattern should match.""" + pattern = SearchPattern( + fields=("name",), + raw="dev", + regex=re.compile("dev"), + ) + + matched, matches = evaluate_match(sample_fields, [pattern]) + + assert matched is True + assert "name" in matches + + def test_single_pattern_no_match(self, sample_fields: WorkspaceFields) -> None: + """Single pattern should not match.""" + pattern = SearchPattern( + fields=("name",), + raw="xyz", + regex=re.compile("xyz"), + ) + + matched, matches = evaluate_match(sample_fields, [pattern]) + + assert matched is False + assert matches == {} + + def test_and_logic_all_match(self, sample_fields: WorkspaceFields) -> None: + """AND logic - all patterns match.""" + p1 = SearchPattern(fields=("name",), raw="dev", regex=re.compile("dev")) + p2 = SearchPattern(fields=("name",), raw="project", regex=re.compile("project")) + + matched, _ = evaluate_match(sample_fields, [p1, p2], match_any=False) + + assert matched is True + + def test_and_logic_partial_no_match(self, sample_fields: WorkspaceFields) -> None: + """AND logic - only some patterns match.""" + p1 = SearchPattern(fields=("name",), raw="dev", regex=re.compile("dev")) + p2 = SearchPattern(fields=("name",), raw="xyz", regex=re.compile("xyz")) + + matched, _ = evaluate_match(sample_fields, [p1, p2], match_any=False) + + assert matched is False + + def test_or_logic_any_match(self, sample_fields: WorkspaceFields) -> None: + """OR logic - any pattern matches.""" + p1 = SearchPattern(fields=("name",), raw="xyz", regex=re.compile("xyz")) + p2 = SearchPattern(fields=("name",), raw="dev", regex=re.compile("dev")) + + matched, _ = evaluate_match(sample_fields, [p1, p2], match_any=True) + + assert matched is True + + def test_window_field_search(self, sample_fields: WorkspaceFields) -> None: + """Search in window field.""" + pattern = SearchPattern( + fields=("window",), + raw="editor", + regex=re.compile("editor"), + ) + + matched, matches = evaluate_match(sample_fields, [pattern]) + + assert matched is True + assert "window" in matches + + def test_pane_field_search(self, sample_fields: WorkspaceFields) -> None: + """Search in pane field.""" + pattern = SearchPattern( + fields=("pane",), + raw="vim", + regex=re.compile("vim"), + ) + + matched, matches = evaluate_match(sample_fields, [pattern]) + + assert matched is True + assert "pane" in matches + + def test_multiple_fields(self, sample_fields: WorkspaceFields) -> None: + """Pattern searches multiple fields.""" + pattern = SearchPattern( + fields=("name", "session_name"), + raw="dev", + regex=re.compile("dev"), + ) + + matched, matches = evaluate_match(sample_fields, [pattern]) + + assert matched is True + # Should find matches in both name and session_name + assert "name" in matches or "session_name" in matches + + +class TestFindSearchMatches: + """Tests for find_search_matches function.""" + + def test_basic_search(self, tmp_path: pathlib.Path) -> None: + """Basic search finds matching workspace.""" + workspace = tmp_path / "dev.yaml" + workspace.write_text("session_name: development\n") + + pattern = SearchPattern( + fields=("session_name",), + raw="dev", + regex=re.compile("dev"), + ) + + results = find_search_matches([(workspace, "global")], [pattern]) + + assert len(results) == 1 + assert results[0]["source"] == "global" + + def test_no_match(self, tmp_path: pathlib.Path) -> None: + """Search returns empty when no match.""" + workspace = tmp_path / "production.yaml" + workspace.write_text("session_name: production\n") + + pattern = SearchPattern( + fields=("name",), + raw="dev", + regex=re.compile("dev"), + ) + + results = find_search_matches([(workspace, "global")], [pattern]) + + assert len(results) == 0 + + def test_invert_match(self, tmp_path: pathlib.Path) -> None: + """Invert match returns non-matching workspaces.""" + workspace = tmp_path / "production.yaml" + workspace.write_text("session_name: production\n") + + pattern = SearchPattern( + fields=("name",), + raw="dev", + regex=re.compile("dev"), + ) + + results = find_search_matches( + [(workspace, "global")], [pattern], invert_match=True + ) + + assert len(results) == 1 + + def test_multiple_workspaces(self, tmp_path: pathlib.Path) -> None: + """Search across multiple workspaces.""" + ws1 = tmp_path / "dev.yaml" + ws1.write_text("session_name: development\n") + + ws2 = tmp_path / "prod.yaml" + ws2.write_text("session_name: production\n") + + pattern = SearchPattern( + fields=("name", "session_name"), + raw="dev", + regex=re.compile("dev"), + ) + + results = find_search_matches([(ws1, "global"), (ws2, "global")], [pattern]) + + assert len(results) == 1 + assert results[0]["fields"]["name"] == "dev" + + +class TestHighlightMatches: + """Tests for highlight_matches function.""" + + def test_no_colors(self) -> None: + """Colors disabled returns original text.""" + colors = Colors(ColorMode.NEVER) + pattern = SearchPattern( + fields=("name",), + raw="dev", + regex=re.compile("dev"), + ) + + result = highlight_matches("development", [pattern], colors=colors) + + assert result == "development" + + def test_with_colors(self) -> None: + """Colors enabled adds ANSI codes.""" + colors = Colors(ColorMode.ALWAYS) + pattern = SearchPattern( + fields=("name",), + raw="dev", + regex=re.compile("dev"), + ) + + result = highlight_matches("development", [pattern], colors=colors) + + assert "\033[" in result # Contains ANSI escape + assert "dev" in result + + def test_no_match(self) -> None: + """No match returns original text.""" + colors = Colors(ColorMode.ALWAYS) + pattern = SearchPattern( + fields=("name",), + raw="xyz", + regex=re.compile("xyz"), + ) + + result = highlight_matches("development", [pattern], colors=colors) + + assert result == "development" + + def test_multiple_matches(self) -> None: + """Multiple matches in same string.""" + colors = Colors(ColorMode.ALWAYS) + pattern = SearchPattern( + fields=("name",), + raw="e", + regex=re.compile("e"), + ) + + result = highlight_matches("development", [pattern], colors=colors) + + # Should contain multiple highlights + assert result.count("\033[") > 1 + + def test_empty_patterns(self) -> None: + """Empty patterns returns original text.""" + colors = Colors(ColorMode.ALWAYS) + + result = highlight_matches("development", [], colors=colors) + + assert result == "development" + + +class TestGetFieldValues: + """Tests for _get_field_values helper.""" + + @pytest.fixture() + def sample_fields(self) -> WorkspaceFields: + """Sample workspace fields.""" + return WorkspaceFields( + name="test", + path="~/.tmuxp/test.yaml", + session_name="test-session", + windows=["editor", "shell"], + panes=["vim", "bash"], + ) + + def test_scalar_field(self, sample_fields: WorkspaceFields) -> None: + """Scalar field returns list with one item.""" + result = _get_field_values(sample_fields, "name") + assert result == ["test"] + + def test_list_field(self, sample_fields: WorkspaceFields) -> None: + """List field returns the list.""" + result = _get_field_values(sample_fields, "windows") + assert result == ["editor", "shell"] + + def test_window_alias(self, sample_fields: WorkspaceFields) -> None: + """Window alias maps to windows.""" + result = _get_field_values(sample_fields, "window") + assert result == ["editor", "shell"] + + def test_pane_alias(self, sample_fields: WorkspaceFields) -> None: + """Pane alias maps to panes.""" + result = _get_field_values(sample_fields, "pane") + assert result == ["vim", "bash"] + + def test_empty_value(self) -> None: + """Empty value returns empty list.""" + fields = WorkspaceFields( + name="", + path="", + session_name="", + windows=[], + panes=[], + ) + result = _get_field_values(fields, "name") + assert result == [] + + +class TestSearchSubparser: + """Tests for search subparser configuration.""" + + def test_parser_creation(self) -> None: + """Subparser can be created successfully.""" + import argparse + + parser = argparse.ArgumentParser() + result = create_search_subparser(parser) + + assert result is parser + + def test_parser_options(self) -> None: + """Parser has expected options.""" + import argparse + + parser = argparse.ArgumentParser() + create_search_subparser(parser) + + # Parse with various options + args = parser.parse_args(["-i", "-S", "-F", "-w", "-v", "--any", "pattern"]) + + assert args.ignore_case is True + assert args.smart_case is True + assert args.fixed_strings is True + assert args.word_regexp is True + assert args.invert_match is True + assert args.match_any is True + assert args.query_terms == ["pattern"] + + def test_output_format_options(self) -> None: + """Parser supports output format options.""" + import argparse + + parser = argparse.ArgumentParser() + create_search_subparser(parser) + + args_json = parser.parse_args(["--json", "test"]) + assert args_json.output_json is True + + args_ndjson = parser.parse_args(["--ndjson", "test"]) + assert args_ndjson.output_ndjson is True + + def test_field_option(self) -> None: + """Parser supports field option.""" + import argparse + + parser = argparse.ArgumentParser() + create_search_subparser(parser) + + args = parser.parse_args(["-f", "name", "-f", "session", "test"]) + + assert args.field == ["name", "session"] + + +class TestOutputSearchResults: + """Tests for _output_search_results function.""" + + def test_no_results_message(self, capsys: pytest.CaptureFixture[str]) -> None: + """No results outputs warning message.""" + colors = Colors(ColorMode.NEVER) + formatter = OutputFormatter(OutputMode.HUMAN) + + _output_search_results([], [], formatter, colors) + formatter.finalize() + + captured = capsys.readouterr() + assert "No matching" in captured.out + + def test_json_output(self, capsys: pytest.CaptureFixture[str]) -> None: + """JSON output mode produces valid JSON.""" + colors = Colors(ColorMode.NEVER) + formatter = OutputFormatter(OutputMode.JSON) + + result = { + "filepath": "/test/dev.yaml", + "source": "global", + "fields": WorkspaceFields( + name="dev", + path="~/.tmuxp/dev.yaml", + session_name="development", + windows=["editor"], + panes=["vim"], + ), + "matches": {"name": ["dev"]}, + } + + _output_search_results([result], [], formatter, colors) + formatter.finalize() + + captured = capsys.readouterr() + data = json.loads(captured.out) + assert len(data) == 1 + assert data[0]["name"] == "dev" + + def test_ndjson_output(self, capsys: pytest.CaptureFixture[str]) -> None: + """NDJSON output mode produces one JSON per line.""" + colors = Colors(ColorMode.NEVER) + formatter = OutputFormatter(OutputMode.NDJSON) + + result = { + "filepath": "/test/dev.yaml", + "source": "global", + "fields": WorkspaceFields( + name="dev", + path="~/.tmuxp/dev.yaml", + session_name="development", + windows=[], + panes=[], + ), + "matches": {"name": ["dev"]}, + } + + _output_search_results([result], [], formatter, colors) + formatter.finalize() + + captured = capsys.readouterr() + lines = captured.out.strip().split("\n") + # Filter out human-readable lines + json_lines = [line for line in lines if line.startswith("{")] + assert len(json_lines) >= 1 + data = json.loads(json_lines[0]) + assert data["name"] == "dev" From d4481009dc32e281674a9491ab68e696e3f65f06 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Jan 2026 10:29:33 -0600 Subject: [PATCH 44/99] docs(cli): Add ls and search examples to main help Update CLI_DESCRIPTION with new command options: ls examples (from 57faf91e, 67a4af25): - --tree: grouped by directory - --full: show window/pane details - --json: machine-readable output search examples: - name:myproject: field-scoped search - -i DEV: case-insensitive matching - --json dev: machine-readable output --- src/tmuxp/cli/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/tmuxp/cli/__init__.py b/src/tmuxp/cli/__init__.py index 6f11994e67..aaf4816978 100644 --- a/src/tmuxp/cli/__init__.py +++ b/src/tmuxp/cli/__init__.py @@ -88,6 +88,9 @@ "ls", [ "tmuxp ls", + "tmuxp ls --tree", + "tmuxp ls --full", + "tmuxp ls --json", ], ), ( @@ -95,6 +98,8 @@ [ "tmuxp search dev", "tmuxp search name:myproject", + "tmuxp search -i DEV", + "tmuxp search --json dev", ], ), ( From 53e924ee3529a12b70e47fc7032f67baa1b6425d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Jan 2026 10:42:46 -0600 Subject: [PATCH 45/99] fix(tests): Add search to valid subcommands and fix TypedDict annotations - Add 'search' to valid_subcommands in test_help_examples.py - Add 'search' to parametrize decorator for subcommand tests - Add test_search_subcommand_examples_are_valid test - Fix mypy errors by explicitly typing WorkspaceSearchResult dicts --- tests/cli/test_help_examples.py | 17 +++++++++++++++++ tests/cli/test_search.py | 5 +++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/tests/cli/test_help_examples.py b/tests/cli/test_help_examples.py index e7768cc560..480290ba6c 100644 --- a/tests/cli/test_help_examples.py +++ b/tests/cli/test_help_examples.py @@ -78,6 +78,7 @@ def test_main_help_examples_are_valid_subcommands() -> None: "ls", "edit", "freeze", + "search", } for example in examples: @@ -100,6 +101,7 @@ def test_main_help_examples_are_valid_subcommands() -> None: "ls", "edit", "freeze", + "search", ], ) def test_subcommand_help_has_examples(subcommand: str) -> None: @@ -232,3 +234,18 @@ def test_debug_info_subcommand_examples_are_valid() -> None: # Verify each example has valid structure for example in examples: assert example.startswith("tmuxp debug-info"), f"Bad example format: {example}" + + +def test_search_subcommand_examples_are_valid() -> None: + """Search subcommand examples should have valid flags.""" + result = subprocess.run( + ["tmuxp", "search", "--help"], + capture_output=True, + text=True, + check=True, + ) + examples = extract_examples_from_help(result.stdout) + + # Verify each example has valid structure + for example in examples: + assert example.startswith("tmuxp search"), f"Bad example format: {example}" diff --git a/tests/cli/test_search.py b/tests/cli/test_search.py index 6e12eab7ab..cf996a12ba 100644 --- a/tests/cli/test_search.py +++ b/tests/cli/test_search.py @@ -17,6 +17,7 @@ SearchPattern, SearchToken, WorkspaceFields, + WorkspaceSearchResult, _get_field_values, _output_search_results, compile_search_patterns, @@ -787,7 +788,7 @@ def test_json_output(self, capsys: pytest.CaptureFixture[str]) -> None: colors = Colors(ColorMode.NEVER) formatter = OutputFormatter(OutputMode.JSON) - result = { + result: WorkspaceSearchResult = { "filepath": "/test/dev.yaml", "source": "global", "fields": WorkspaceFields( @@ -813,7 +814,7 @@ def test_ndjson_output(self, capsys: pytest.CaptureFixture[str]) -> None: colors = Colors(ColorMode.NEVER) formatter = OutputFormatter(OutputMode.NDJSON) - result = { + result: WorkspaceSearchResult = { "filepath": "/test/dev.yaml", "source": "global", "fields": WorkspaceFields( From cd4b9ba8392d8fc591e8f5bab623b2194971cb73 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Jan 2026 10:53:32 -0600 Subject: [PATCH 46/99] feat(cli[search]): Show help when invoked without arguments Use argparse set_defaults pattern to store print_help callable, then invoke it when no query terms are provided. This makes `tmuxp search` equivalent to `tmuxp search --help`. --- src/tmuxp/cli/search.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/tmuxp/cli/search.py b/src/tmuxp/cli/search.py index 6a258e8c21..ad7c487120 100644 --- a/src/tmuxp/cli/search.py +++ b/src/tmuxp/cli/search.py @@ -1096,6 +1096,7 @@ class CLISearchNamespace(argparse.Namespace): match_any: bool output_json: bool output_ndjson: bool + print_help: t.Callable[[], None] def create_search_subparser( @@ -1190,6 +1191,9 @@ def create_search_subparser( help="output as NDJSON (one JSON per line)", ) + # Store print_help for use when no arguments provided + parser.set_defaults(print_help=parser.print_help) + return parser @@ -1227,9 +1231,8 @@ def command_search( query_terms = args.query_terms if args else [] if not query_terms: - formatter.emit_text(colors.warning("No search pattern provided.")) - formatter.emit_text(colors.muted("Usage: tmuxp search PATTERN [PATTERN ...]")) - formatter.finalize() + if args and hasattr(args, "print_help"): + args.print_help() return # Parse and compile patterns From 203dc44e33789b19701b7c000d8a87a03f49459a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Jan 2026 10:57:19 -0600 Subject: [PATCH 47/99] test(cli[search]): Add test for no-args help behavior Verify that `tmuxp search` with no arguments shows help output and exits with code 0, equivalent to `tmuxp search --help`. --- tests/cli/test_help_examples.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/cli/test_help_examples.py b/tests/cli/test_help_examples.py index 480290ba6c..88234e6232 100644 --- a/tests/cli/test_help_examples.py +++ b/tests/cli/test_help_examples.py @@ -249,3 +249,16 @@ def test_search_subcommand_examples_are_valid() -> None: # Verify each example has valid structure for example in examples: assert example.startswith("tmuxp search"), f"Bad example format: {example}" + + +def test_search_no_args_shows_help() -> None: + """Running 'tmuxp search' with no args shows help.""" + result = subprocess.run( + ["tmuxp", "search"], + capture_output=True, + text=True, + ) + # Should show help (usage line present) + assert "usage: tmuxp search" in result.stdout + # Should exit successfully (not error) + assert result.returncode == 0 From 0319a0ffc7a3a5f007ea9bba0af0d07591ee815c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Jan 2026 11:57:38 -0600 Subject: [PATCH 48/99] feat(cli[debug-info]): Add --json output mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add --json flag to output structured JSON for machine parsing - Use PrivatePath for all paths (home → ~) in JSON output - Refactor to _collect_debug_info() and _format_human_output() - Update help examples in debug-info and main CLI - Add comprehensive tests with NamedTuple parametrization: - test_debug_info_output_modes (parametrized human/JSON) - test_debug_info_json_output (structure validation) - test_debug_info_json_no_ansi (no ANSI codes in JSON) - test_debug_info_json_paths_use_private_path (privacy) --- src/tmuxp/cli/__init__.py | 1 + src/tmuxp/cli/debug_info.py | 239 +++++++++++++++++++++++++++-------- tests/cli/test_debug_info.py | 137 +++++++++++++++++++- 3 files changed, 320 insertions(+), 57 deletions(-) diff --git a/src/tmuxp/cli/__init__.py b/src/tmuxp/cli/__init__.py index aaf4816978..178f33916b 100644 --- a/src/tmuxp/cli/__init__.py +++ b/src/tmuxp/cli/__init__.py @@ -134,6 +134,7 @@ "debug-info", [ "tmuxp debug-info", + "tmuxp debug-info --json", ], ), ), diff --git a/src/tmuxp/cli/debug_info.py b/src/tmuxp/cli/debug_info.py index 986b1df44e..ffed3980dd 100644 --- a/src/tmuxp/cli/debug_info.py +++ b/src/tmuxp/cli/debug_info.py @@ -30,6 +30,12 @@ "tmuxp debug-info", ], ), + ( + "Machine-readable output:", + [ + "tmuxp debug-info --json", + ], + ), ), ) @@ -45,88 +51,213 @@ class CLIDebugInfoNamespace(argparse.Namespace): """Typed :class:`argparse.Namespace` for tmuxp debug-info command.""" color: CLIColorModeLiteral + output_json: bool def create_debug_info_subparser( parser: argparse.ArgumentParser, ) -> argparse.ArgumentParser: """Augment :class:`argparse.ArgumentParser` with ``debug-info`` subcommand.""" + parser.add_argument( + "--json", + action="store_true", + dest="output_json", + help="output as JSON", + ) return parser -def command_debug_info( - args: CLIDebugInfoNamespace | None = None, - parser: argparse.ArgumentParser | None = None, -) -> None: - """Entrypoint for ``tmuxp debug-info`` to print debug info to submit with issues.""" - # Get color mode from args or default to AUTO - color_mode = get_color_mode(args.color if args else None) - colors = Colors(color_mode) +def _private(path: pathlib.Path | str | None) -> str: + """Privacy-mask a path by collapsing home directory to ~. - def private(path: pathlib.Path | str | None) -> str: - """Privacy-mask a path by collapsing home directory to ~.""" - if path is None: - return "" - return str(PrivatePath(path)) + Parameters + ---------- + path : pathlib.Path | str | None + Path to mask. + + Returns + ------- + str + Path with home directory replaced by ~. + + Examples + -------- + >>> _private(None) + '' + >>> _private('/usr/bin/tmux') + '/usr/bin/tmux' + """ + if path is None: + return "" + return str(PrivatePath(path)) + + +def _collect_debug_info() -> dict[str, t.Any]: + """Collect debug information as a structured dictionary. + + All paths are privacy-masked using PrivatePath (home → ~). + + Returns + ------- + dict[str, Any] + Debug information with environment, versions, paths, and tmux state. + + Examples + -------- + >>> data = _collect_debug_info() + >>> 'environment' in data + True + >>> 'tmux_version' in data + True + """ + # Collect tmux command outputs + sessions_resp = tmux_cmd("list-sessions") + windows_resp = tmux_cmd("list-windows") + panes_resp = tmux_cmd("list-panes") + global_opts_resp = tmux_cmd("show-options", "-g") + window_opts_resp = tmux_cmd("show-window-options", "-g") + + return { + "environment": { + "dist": platform.platform(), + "arch": platform.machine(), + "uname": list(platform.uname()[:3]), + "version": platform.version(), + }, + "python_version": " ".join(sys.version.split("\n")), + "system_path": collapse_home_in_string(os.environ.get("PATH", "")), + "tmux_version": str(get_version()), + "libtmux_version": libtmux_version, + "tmuxp_version": __version__, + "tmux_path": _private(shutil.which("tmux")), + "tmuxp_path": _private(tmuxp_path), + "shell": _private(os.environ.get("SHELL", "")), + "tmux": { + "sessions": sessions_resp.stdout, + "windows": windows_resp.stdout, + "panes": panes_resp.stdout, + "global_options": global_opts_resp.stdout, + "window_options": window_opts_resp.stdout, + }, + } - def format_tmux_resp(std_resp: tmux_cmd) -> str: - """Format tmux command response with syntax highlighting.""" - stdout_lines = [] - for line in std_resp.stdout: - formatted = colors.format_tmux_option(line) - stdout_lines.append(f"\t{formatted}") - stderr_formatted = "" - if std_resp.stderr: - stderr_lines = "\n".join(f"\t{line}" for line in std_resp.stderr) - if stderr_lines.strip(): - stderr_formatted = colors.error(stderr_lines) +def _format_human_output(data: dict[str, t.Any], colors: Colors) -> str: + """Format debug info as human-readable colored output. - parts = ["\n".join(stdout_lines)] - if stderr_formatted: - parts.append(stderr_formatted) - return "\n".join(parts) + Parameters + ---------- + data : dict[str, Any] + Debug information dictionary. + colors : Colors + Color manager for formatting. - # Build environment section with indented key-value pairs + Returns + ------- + str + Formatted human-readable output. + + Examples + -------- + >>> from tmuxp.cli._colors import ColorMode, Colors + >>> colors = Colors(ColorMode.NEVER) + >>> data = { + ... "environment": { + ... "dist": "Linux", + ... "arch": "x86_64", + ... "uname": ["Linux", "host", "6.0"], + ... "version": "#1 SMP", + ... }, + ... "python_version": "3.12.0", + ... "system_path": "/usr/bin", + ... "tmux_version": "3.4", + ... "libtmux_version": "0.40.0", + ... "tmuxp_version": "1.50.0", + ... "tmux_path": "/usr/bin/tmux", + ... "tmuxp_path": "~/tmuxp", + ... "shell": "/bin/bash", + ... "tmux": { + ... "sessions": [], + ... "windows": [], + ... "panes": [], + ... "global_options": [], + ... "window_options": [], + ... }, + ... } + >>> output = _format_human_output(data, colors) + >>> "environment" in output + True + """ + + def format_tmux_section(lines: list[str]) -> str: + """Format tmux command output with syntax highlighting.""" + formatted_lines = [] + for line in lines: + formatted = colors.format_tmux_option(line) + formatted_lines.append(f"\t{formatted}") + return "\n".join(formatted_lines) + + env = data["environment"] env_items = [ - f"\t{colors.format_kv('dist', platform.platform())}", - f"\t{colors.format_kv('arch', platform.machine())}", - f"\t{colors.format_kv('uname', '; '.join(platform.uname()[:3]))}", - f"\t{colors.format_kv('version', platform.version())}", + f"\t{colors.format_kv('dist', env['dist'])}", + f"\t{colors.format_kv('arch', env['arch'])}", + f"\t{colors.format_kv('uname', '; '.join(env['uname']))}", + f"\t{colors.format_kv('version', env['version'])}", ] + tmux_data = data["tmux"] output = [ colors.format_separator(), f"{colors.format_label('environment')}:\n" + "\n".join(env_items), colors.format_separator(), + colors.format_kv("python version", data["python_version"]), + colors.format_kv("system PATH", data["system_path"]), + colors.format_kv("tmux version", colors.format_version(data["tmux_version"])), colors.format_kv( - "python version", - " ".join(sys.version.split("\n")), + "libtmux version", colors.format_version(data["libtmux_version"]) ), - colors.format_kv( - "system PATH", - collapse_home_in_string(os.environ.get("PATH", "")), - ), - colors.format_kv("tmux version", colors.format_version(str(get_version()))), - colors.format_kv("libtmux version", colors.format_version(libtmux_version)), - colors.format_kv("tmuxp version", colors.format_version(__version__)), - colors.format_kv( - "tmux path", - colors.format_path(private(shutil.which("tmux"))), - ), - colors.format_kv("tmuxp path", colors.format_path(private(tmuxp_path))), - colors.format_kv("shell", private(os.environ.get("SHELL", ""))), + colors.format_kv("tmuxp version", colors.format_version(data["tmuxp_version"])), + colors.format_kv("tmux path", colors.format_path(data["tmux_path"])), + colors.format_kv("tmuxp path", colors.format_path(data["tmuxp_path"])), + colors.format_kv("shell", data["shell"]), colors.format_separator(), f"{colors.format_label('tmux sessions')}:\n" - + format_tmux_resp(tmux_cmd("list-sessions")), + + format_tmux_section(tmux_data["sessions"]), f"{colors.format_label('tmux windows')}:\n" - + format_tmux_resp(tmux_cmd("list-windows")), + + format_tmux_section(tmux_data["windows"]), f"{colors.format_label('tmux panes')}:\n" - + format_tmux_resp(tmux_cmd("list-panes")), + + format_tmux_section(tmux_data["panes"]), f"{colors.format_label('tmux global options')}:\n" - + format_tmux_resp(tmux_cmd("show-options", "-g")), + + format_tmux_section(tmux_data["global_options"]), f"{colors.format_label('tmux window options')}:\n" - + format_tmux_resp(tmux_cmd("show-window-options", "-g")), + + format_tmux_section(tmux_data["window_options"]), ] - tmuxp_echo("\n".join(output)) + return "\n".join(output) + + +def command_debug_info( + args: CLIDebugInfoNamespace | None = None, + 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 + + # Get color mode (only used for human output) + color_mode = get_color_mode(args.color if args else None) + colors = Colors(color_mode) + + # Collect debug info + data = _collect_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() + else: + tmuxp_echo(_format_human_output(data, colors)) diff --git a/tests/cli/test_debug_info.py b/tests/cli/test_debug_info.py index 1729f1e9cc..bb1c0bb479 100644 --- a/tests/cli/test_debug_info.py +++ b/tests/cli/test_debug_info.py @@ -1,15 +1,68 @@ -"""CLI tests for tmuxp debuginfo.""" +"""CLI tests for tmuxp debug-info.""" from __future__ import annotations +import json import typing as t +import pytest + from tmuxp import cli if t.TYPE_CHECKING: import pathlib - import pytest + +class DebugInfoOutputFixture(t.NamedTuple): + """Test fixture for debug-info output modes.""" + + test_id: str + args: list[str] + expected_keys: list[str] + is_json: bool + + +DEBUG_INFO_OUTPUT_FIXTURES: list[DebugInfoOutputFixture] = [ + DebugInfoOutputFixture( + test_id="human_output_has_labels", + args=["debug-info"], + expected_keys=["environment", "python version", "tmux version"], + is_json=False, + ), + DebugInfoOutputFixture( + test_id="json_output_valid", + args=["debug-info", "--json"], + expected_keys=["environment", "python_version", "tmux_version"], + is_json=True, + ), +] + + +@pytest.mark.parametrize( + DEBUG_INFO_OUTPUT_FIXTURES[0]._fields, + [pytest.param(*f, id=f.test_id) for f in DEBUG_INFO_OUTPUT_FIXTURES], +) +def test_debug_info_output_modes( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + test_id: str, + args: list[str], + expected_keys: list[str], + is_json: bool, +) -> None: + """Test debug-info output modes (human and JSON).""" + monkeypatch.setenv("SHELL", "/bin/bash") + + cli.cli(args) + output = capsys.readouterr().out + + if is_json: + data = json.loads(output) + for key in expected_keys: + assert key in data, f"Expected key '{key}' in JSON output" + else: + for key in expected_keys: + assert key in output, f"Expected '{key}' in human output" def test_debug_info_cli( @@ -17,7 +70,7 @@ def test_debug_info_cli( tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str], ) -> None: - """Basic CLI test for tmuxp debug-info.""" + """Basic CLI test for tmuxp debug-info (human output).""" monkeypatch.setenv("SHELL", "/bin/bash") cli.cli(["debug-info"]) @@ -36,3 +89,81 @@ def test_debug_info_cli( assert "tmux panes" in cli_output assert "tmux global options" in cli_output assert "tmux window options" in cli_output + + +def test_debug_info_json_output( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """JSON output is valid JSON with expected structure.""" + monkeypatch.setenv("SHELL", "/bin/bash") + + cli.cli(["debug-info", "--json"]) + output = capsys.readouterr().out + + data = json.loads(output) + + # Top-level keys + assert "environment" in data + assert "python_version" in data + assert "system_path" in data + assert "tmux_version" in data + assert "libtmux_version" in data + assert "tmuxp_version" in data + assert "tmux_path" in data + assert "tmuxp_path" in data + assert "shell" in data + assert "tmux" in data + + # Environment structure + env = data["environment"] + assert "dist" in env + assert "arch" in env + assert "uname" in env + assert "version" in env + assert isinstance(env["uname"], list) + + # Tmux structure + tmux = data["tmux"] + assert "sessions" in tmux + assert "windows" in tmux + assert "panes" in tmux + assert "global_options" in tmux + assert "window_options" in tmux + assert isinstance(tmux["sessions"], list) + + +def test_debug_info_json_no_ansi( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """JSON output should not contain ANSI escape codes.""" + monkeypatch.setenv("SHELL", "/bin/bash") + + cli.cli(["debug-info", "--json"]) + output = capsys.readouterr().out + + # ANSI escape codes start with \x1b[ or \033[ + assert "\x1b[" not in output, "JSON output contains ANSI escape codes" + assert "\033[" not in output, "JSON output contains ANSI escape codes" + + +def test_debug_info_json_paths_use_private_path( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """JSON output should mask home directory with ~.""" + import pathlib + + # Set SHELL to a path under home directory + shell_path = pathlib.Path.home() / ".local" / "bin" / "zsh" + monkeypatch.setenv("SHELL", str(shell_path)) + + cli.cli(["debug-info", "--json"]) + output = capsys.readouterr().out + data = json.loads(output) + + # The shell path should be masked with ~ + assert data["shell"] == "~/.local/bin/zsh", ( + f"Expected shell path to be masked with ~, got: {data['shell']}" + ) From 3a0b9e47855f1e0c8eb92046fd30f72ba060c723 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Jan 2026 08:04:02 -0600 Subject: [PATCH 49/99] docs(CHANGES): Add CLI Colors feature entry for #1006 --- CHANGES | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CHANGES b/CHANGES index 4064aceb6e..cda75f5bb2 100644 --- a/CHANGES +++ b/CHANGES @@ -31,6 +31,20 @@ $ pipx install --suffix=@next 'tmuxp' --pip-args '\--pre' --force +### Features + +#### CLI Colors (#1006) + +New semantic color output for all CLI commands: + +- New `--color` flag (auto/always/never) on root CLI for controlling color output +- Respects `NO_COLOR` and `FORCE_COLOR` environment variables per [no-color.org](https://no-color.org) standard +- Semantic color methods: `success()` (green), `warning()` (yellow), `error()` (red), `info()` (cyan), `highlight()` (magenta), `muted()` (blue) +- All commands updated with colored output: `load`, `ls`, `freeze`, `convert`, `import`, `edit`, `shell`, `debug-info` +- Interactive prompts enhanced with color support +- `PrivatePath` utility masks home directory as `~` for privacy protection in output +- Beautiful `--help` output with usage examples + ### Development #### Makefile -> Justfile (#1005) From a99a6a9a0db7bc7e8453a518079bb9e3938197bf Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Jan 2026 12:03:48 -0600 Subject: [PATCH 50/99] docs(CHANGES): Add search, ls enhancements, debug-info --json Document new features added to CLI Colors PR #1006: - New tmuxp search command with field-scoped search - Enhanced tmuxp ls with --tree, --full, --json, --ndjson - Local workspace discovery from cwd and parents - tmuxp debug-info --json for machine-readable output --- CHANGES | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/CHANGES b/CHANGES index cda75f5bb2..07b255f5ef 100644 --- a/CHANGES +++ b/CHANGES @@ -45,6 +45,30 @@ New semantic color output for all CLI commands: - `PrivatePath` utility masks home directory as `~` for privacy protection in output - Beautiful `--help` output with usage examples +#### Search Command (#1006) + +New `tmuxp search` command for finding workspace files: + +- Field-scoped search with prefixes: `name:`, `session:`, `path:`, `window:`, `pane:` +- Matching options: `-i` (ignore-case), `-S` (smart-case), `-F` (fixed-strings), `-w` (word) +- Logic operators: `--any` for OR, `-v` for invert match +- Output formats: human (with match highlighting), `--json`, `--ndjson` +- Searches local (cwd and parents) and global (~/.tmuxp/) workspaces + +#### Enhanced ls Command (#1006) + +New output options for `tmuxp ls`: + +- `--tree`: Display workspaces grouped by directory +- `--full`: Include complete parsed config content +- `--json` / `--ndjson`: Machine-readable output formats +- Local workspace discovery from current directory and parents +- Source field distinguishes "local" vs "global" workspaces + +#### JSON Output for debug-info (#1006) + +- `tmuxp debug-info --json`: Structured JSON output for machine parsing + ### Development #### Makefile -> Justfile (#1005) From 6f943d25d1fc8e7c75fe6581be76a78ff03a1253 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Jan 2026 12:07:57 -0600 Subject: [PATCH 51/99] docs(CHANGES): Mention jq compatibility for JSON output commands Add explicit mentions of `jq` piping and automation use cases for all JSON output features (search, ls, debug-info) to improve discoverability. --- CHANGES | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGES b/CHANGES index 07b255f5ef..8685728623 100644 --- a/CHANGES +++ b/CHANGES @@ -52,7 +52,7 @@ New `tmuxp search` command for finding workspace files: - Field-scoped search with prefixes: `name:`, `session:`, `path:`, `window:`, `pane:` - Matching options: `-i` (ignore-case), `-S` (smart-case), `-F` (fixed-strings), `-w` (word) - Logic operators: `--any` for OR, `-v` for invert match -- Output formats: human (with match highlighting), `--json`, `--ndjson` +- Output formats: human (with match highlighting), `--json`, `--ndjson` for automation and piping to `jq` - Searches local (cwd and parents) and global (~/.tmuxp/) workspaces #### Enhanced ls Command (#1006) @@ -61,13 +61,13 @@ New output options for `tmuxp ls`: - `--tree`: Display workspaces grouped by directory - `--full`: Include complete parsed config content -- `--json` / `--ndjson`: Machine-readable output formats +- `--json` / `--ndjson`: Machine-readable output for automation and piping to `jq` - Local workspace discovery from current directory and parents - Source field distinguishes "local" vs "global" workspaces #### JSON Output for debug-info (#1006) -- `tmuxp debug-info --json`: Structured JSON output for machine parsing +- `tmuxp debug-info --json`: Structured JSON output for automation, issue reporting, and piping to `jq` ### Development From 3a2bf497ae87a0c408de2baee5c1befd9b29203c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Jan 2026 13:01:12 -0600 Subject: [PATCH 52/99] feat(cli[ls]): Add visual hierarchy with heading() and global workspace dirs - Add heading() method to Colors class for section headers (cyan+bold) - Use highlight() for workspace names (magenta+bold) to distinguish from headers - Add get_workspace_dir_candidates() to show all checked directories - Display active directory in header: "Global workspaces (~/.tmuxp):" - Show candidate directories with source labels (Legacy, XDG, etc.) - Add comprehensive tests for new functionality Color hierarchy: - L0 Headers: heading() cyan+bold - L1 Items: highlight() magenta+bold - L2 Paths: info() cyan - L3 Labels: muted() blue --- src/tmuxp/cli/_colors.py | 24 ++++ src/tmuxp/cli/ls.py | 161 ++++++++++++++++++++-- src/tmuxp/workspace/finders.py | 80 +++++++++++ tests/cli/test_ls.py | 237 ++++++++++++++++++++++++++++++--- tests/workspace/test_finder.py | 157 ++++++++++++++++++++++ 5 files changed, 632 insertions(+), 27 deletions(-) diff --git a/src/tmuxp/cli/_colors.py b/src/tmuxp/cli/_colors.py index 50dca76323..840838e429 100644 --- a/src/tmuxp/cli/_colors.py +++ b/src/tmuxp/cli/_colors.py @@ -351,6 +351,30 @@ def muted(self, text: str) -> str: """ return self._colorize(text, self.MUTED, bold=False) + def heading(self, text: str) -> str: + """Format text as a section heading (cyan, bold). + + Used for section headers like 'Local workspaces:' or 'Global workspaces:'. + Distinguished from info() by being bold. + + Parameters + ---------- + text : str + Text to format. + + Returns + ------- + str + Formatted text. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.heading("Local workspaces:") + 'Local workspaces:' + """ + return self._colorize(text, self.INFO, bold=True) + # Formatting helpers for structured output def format_label(self, label: str) -> str: diff --git a/src/tmuxp/cli/ls.py b/src/tmuxp/cli/ls.py index 14f98fe1b4..7f0118540a 100644 --- a/src/tmuxp/cli/ls.py +++ b/src/tmuxp/cli/ls.py @@ -34,10 +34,14 @@ from tmuxp._internal.config_reader import ConfigReader from tmuxp._internal.private_path import PrivatePath from tmuxp.workspace.constants import VALID_WORKSPACE_DIR_FILE_EXTENSIONS -from tmuxp.workspace.finders import find_local_workspace_files, get_workspace_dir +from tmuxp.workspace.finders import ( + find_local_workspace_files, + get_workspace_dir, + get_workspace_dir_candidates, +) from ._colors import Colors, build_description, get_color_mode -from ._output import OutputFormatter, get_output_mode +from ._output import OutputFormatter, OutputMode, get_output_mode LS_DESCRIPTION = build_description( """ @@ -338,6 +342,7 @@ def _output_flat( colors: Colors, *, full: bool = False, + global_dir_candidates: list[dict[str, t.Any]] | None = None, ) -> None: """Output workspaces in flat list format. @@ -353,6 +358,8 @@ def _output_flat( Color manager. full : bool If True, show full config details in tree format. Default False. + global_dir_candidates : list[dict[str, Any]] | None + List of global workspace directory candidates with metadata. """ # Separate by source for human output grouping local_workspaces = [ws for ws in workspaces if ws["source"] == "local"] @@ -361,8 +368,8 @@ def _output_flat( def output_workspace(ws: dict[str, t.Any], show_path: bool) -> None: """Output a single workspace.""" formatter.emit(ws) - path_info = f" {colors.muted(ws['path'])}" if show_path else "" - formatter.emit_text(f" {colors.info(ws['name'])}{path_info}") + path_info = f" {colors.info(ws['path'])}" if show_path else "" + formatter.emit_text(f" {colors.highlight(ws['name'])}{path_info}") # With --full, show config tree if full and ws.get("config"): @@ -371,18 +378,58 @@ def output_workspace(ws: dict[str, t.Any], show_path: bool) -> None: # Output local workspaces first (closest to user's context) if local_workspaces: - formatter.emit_text(colors.muted("Local workspaces:")) + formatter.emit_text(colors.heading("Local workspaces:")) for ws in local_workspaces: output_workspace(ws, show_path=True) - # Output global workspaces + # Output global workspaces with active directory in header if global_workspaces: if local_workspaces: formatter.emit_text("") # Blank line separator - formatter.emit_text(colors.muted("Global workspaces:")) + + # Find active directory for header + active_dir = "" + if global_dir_candidates: + for candidate in global_dir_candidates: + if candidate["active"]: + active_dir = candidate["path"] + break + + if active_dir: + formatter.emit_text(colors.heading(f"Global workspaces ({active_dir}):")) + else: + formatter.emit_text(colors.heading("Global workspaces:")) + for ws in global_workspaces: output_workspace(ws, show_path=False) + # Output global workspace directories section + if global_dir_candidates: + formatter.emit_text("") + formatter.emit_text(colors.heading("Global workspace directories:")) + for candidate in global_dir_candidates: + path = candidate["path"] + source = candidate.get("source", "") + source_prefix = f"{source}: " if source else "" + if candidate["exists"]: + count = candidate["workspace_count"] + status = f"{count} workspace{'s' if count != 1 else ''}" + if candidate["active"]: + status += ", active" + formatter.emit_text( + f" {colors.muted(source_prefix)}{colors.info(path)} " + f"({colors.success(status)})" + ) + else: + formatter.emit_text( + f" {colors.muted(source_prefix)}{colors.info(path)} ({status})" + ) + else: + formatter.emit_text( + f" {colors.muted(source_prefix)}{colors.info(path)} " + f"({colors.muted('not found')})" + ) + def _output_tree( workspaces: list[dict[str, t.Any]], @@ -390,6 +437,7 @@ def _output_tree( colors: Colors, *, full: bool = False, + global_dir_candidates: list[dict[str, t.Any]] | None = None, ) -> None: """Output workspaces grouped by directory (tree view). @@ -403,6 +451,8 @@ def _output_tree( Color manager. full : bool If True, show full config details in tree format. Default False. + global_dir_candidates : list[dict[str, Any]] | None + List of global workspace directory candidates with metadata. """ # Group by parent directory by_directory: dict[str, list[dict[str, t.Any]]] = {} @@ -428,13 +478,40 @@ def _output_tree( session_info = "" if ws_session and ws_session != ws_name: session_info = f" {colors.muted(f'→ {ws_session}')}" - formatter.emit_text(f" {colors.info(ws_name)}{session_info}") + formatter.emit_text(f" {colors.highlight(ws_name)}{session_info}") # With --full, show config tree if full and ws.get("config"): for line in _render_config_tree(ws["config"], colors): formatter.emit_text(f" {line}") + # Output global workspace directories section + if global_dir_candidates: + formatter.emit_text("") + formatter.emit_text(colors.heading("Global workspace directories:")) + for candidate in global_dir_candidates: + path = candidate["path"] + source = candidate.get("source", "") + source_prefix = f"{source}: " if source else "" + if candidate["exists"]: + count = candidate["workspace_count"] + status = f"{count} workspace{'s' if count != 1 else ''}" + if candidate["active"]: + status += ", active" + formatter.emit_text( + f" {colors.muted(source_prefix)}{colors.info(path)} " + f"({colors.success(status)})" + ) + else: + formatter.emit_text( + f" {colors.muted(source_prefix)}{colors.info(path)} ({status})" + ) + else: + formatter.emit_text( + f" {colors.muted(source_prefix)}{colors.info(path)} " + f"({colors.muted('not found')})" + ) + def command_ls( args: CLILsNamespace | None = None, @@ -456,6 +533,9 @@ 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) @@ -468,6 +548,9 @@ def command_ls( output_mode = get_output_mode(output_json, output_ndjson) formatter = OutputFormatter(output_mode) + # Get global workspace directory candidates + global_dir_candidates = get_workspace_dir_candidates() + # 1. Collect local workspace files (cwd and parents) local_files = find_local_workspace_files() workspaces: list[dict[str, t.Any]] = [ @@ -486,13 +569,67 @@ def command_ls( if not workspaces: formatter.emit_text(colors.warning("No workspaces found.")) - formatter.finalize() + # Still show global workspace directories even with no workspaces + if output_mode == OutputMode.HUMAN: + formatter.emit_text("") + formatter.emit_text(colors.heading("Global workspace directories:")) + for candidate in global_dir_candidates: + path = candidate["path"] + source = candidate.get("source", "") + source_prefix = f"{source}: " if source else "" + if candidate["exists"]: + if candidate["active"]: + formatter.emit_text( + f" {colors.muted(source_prefix)}{colors.info(path)} " + f"({colors.success('0 workspaces, active')})" + ) + else: + formatter.emit_text( + f" {colors.muted(source_prefix)}{colors.info(path)} " + f"(0 workspaces)" + ) + else: + formatter.emit_text( + f" {colors.muted(source_prefix)}{colors.info(path)} " + f"({colors.muted('not found')})" + ) + elif output_mode == OutputMode.JSON: + # Output structured JSON with empty workspaces + output_data = { + "workspaces": [], + "global_workspace_dirs": global_dir_candidates, + } + sys.stdout.write(json.dumps(output_data, indent=2) + "\n") + sys.stdout.flush() + # NDJSON: just output nothing for empty workspaces return - # Output based on mode + # JSON mode: output structured object instead of using formatter + if output_mode == OutputMode.JSON: + output_data = { + "workspaces": workspaces, + "global_workspace_dirs": global_dir_candidates, + } + sys.stdout.write(json.dumps(output_data, indent=2) + "\n") + sys.stdout.flush() + return + + # Human and NDJSON output if tree: - _output_tree(workspaces, formatter, colors, full=full) + _output_tree( + workspaces, + formatter, + colors, + full=full, + global_dir_candidates=global_dir_candidates, + ) else: - _output_flat(workspaces, formatter, colors, full=full) + _output_flat( + workspaces, + formatter, + colors, + full=full, + global_dir_candidates=global_dir_candidates, + ) formatter.finalize() diff --git a/src/tmuxp/workspace/finders.py b/src/tmuxp/workspace/finders.py index 84e6048685..00e01b50b0 100644 --- a/src/tmuxp/workspace/finders.py +++ b/src/tmuxp/workspace/finders.py @@ -9,6 +9,7 @@ from colorama import Fore +from tmuxp._internal.private_path import PrivatePath from tmuxp.cli.utils import tmuxp_echo from tmuxp.workspace.constants import VALID_WORKSPACE_DIR_FILE_EXTENSIONS @@ -197,6 +198,85 @@ def get_workspace_dir() -> str: return path +def get_workspace_dir_candidates() -> list[dict[str, t.Any]]: + """Return all candidate workspace directories with existence status. + + Returns a list of all directories that tmuxp checks for workspaces, + in priority order, with metadata about each. + + The priority order is: + 1. ``TMUXP_CONFIGDIR`` environment variable (if set) + 2. ``XDG_CONFIG_HOME/tmuxp`` (if XDG_CONFIG_HOME set) OR ``~/.config/tmuxp/`` + 3. ``~/.tmuxp`` (legacy default) + + Returns + ------- + list[dict[str, Any]] + List of dicts with: + - path: str (privacy-masked via PrivatePath) + - source: str (e.g., "$TMUXP_CONFIGDIR", "$XDG_CONFIG_HOME/tmuxp", "Legacy") + - exists: bool + - workspace_count: int (0 if not exists) + - active: bool (True if this is the directory get_workspace_dir() returns) + + Examples + -------- + >>> candidates = get_workspace_dir_candidates() + >>> isinstance(candidates, list) + True + >>> all('path' in c and 'exists' in c for c in candidates) + True + """ + # Build list of candidate paths with sources (same logic as get_workspace_dir) + # Each entry is (raw_path, source_label) + path_sources: list[tuple[str, str]] = [] + if "TMUXP_CONFIGDIR" in os.environ: + path_sources.append((os.environ["TMUXP_CONFIGDIR"], "$TMUXP_CONFIGDIR")) + if "XDG_CONFIG_HOME" in os.environ: + path_sources.append( + ( + os.path.join(os.environ["XDG_CONFIG_HOME"], "tmuxp"), + "$XDG_CONFIG_HOME/tmuxp", + ) + ) + else: + path_sources.append(("~/.config/tmuxp/", "XDG default")) + path_sources.append(("~/.tmuxp", "Legacy")) + + # Get the active directory for comparison + active_dir = get_workspace_dir() + + candidates: list[dict[str, t.Any]] = [] + for raw_path, source in path_sources: + expanded = os.path.expanduser(raw_path) + exists = os.path.isdir(expanded) + + # Count workspace files if directory exists + workspace_count = 0 + if exists: + workspace_count = len( + [ + f + for f in os.listdir(expanded) + if not f.startswith(".") + and os.path.splitext(f)[1].lower() + in VALID_WORKSPACE_DIR_FILE_EXTENSIONS + ] + ) + + candidates.append( + { + "path": str(PrivatePath(expanded)), + "source": source, + "exists": exists, + "workspace_count": workspace_count, + "active": expanded == active_dir, + } + ) + + return candidates + + def find_workspace_file( workspace_file: StrPath, workspace_dir: StrPath | None = None, diff --git a/tests/cli/test_ls.py b/tests/cli/test_ls.py index 5f2914854e..89ed2a8662 100644 --- a/tests/cli/test_ls.py +++ b/tests/cli/test_ls.py @@ -172,8 +172,8 @@ def test_ls_cli( cli_output = capsys.readouterr().out - # Output now has headers, check for workspace names - assert "Global workspaces:" in cli_output + # Output now has headers with directory path, check for workspace names + assert "Global workspaces (~/.tmuxp):" in cli_output for stem in stems: assert stem in cli_output @@ -201,14 +201,19 @@ def test_ls_json_output( output = capsys.readouterr().out data = json.loads(output) - assert isinstance(data, list) - assert len(data) == 2 + # JSON output is now an object with workspaces and global_workspace_dirs + assert isinstance(data, dict) + assert "workspaces" in data + assert "global_workspace_dirs" in data - names = {item["name"] for item in data} + workspaces = data["workspaces"] + assert len(workspaces) == 2 + + names = {item["name"] for item in workspaces} assert names == {"dev", "prod"} # Verify all expected fields are present - for item in data: + for item in workspaces: assert "name" in item assert "path" in item assert "format" in item @@ -411,7 +416,7 @@ def test_ls_shows_local_and_global( output = capsys.readouterr().out assert "Local workspaces:" in output - assert "Global workspaces:" in output + assert "Global workspaces (~/.tmuxp):" in output assert ".tmuxp" in output assert "global" in output @@ -442,11 +447,15 @@ def test_ls_json_includes_source_for_local( output = capsys.readouterr().out data = json.loads(output) - sources = {item["source"] for item in data} + # JSON output is now an object with workspaces and global_workspace_dirs + assert isinstance(data, dict) + workspaces = data["workspaces"] + + sources = {item["source"] for item in workspaces} assert sources == {"local", "global"} - local_items = [item for item in data if item["source"] == "local"] - global_items = [item for item in data if item["source"] == "global"] + local_items = [item for item in workspaces if item["source"] == "local"] + global_items = [item for item in workspaces if item["source"] == "global"] assert len(local_items) == 1 assert len(global_items) == 1 @@ -548,10 +557,14 @@ def test_ls_json_full_includes_config( output = capsys.readouterr().out data = json.loads(output) - assert len(data) == 1 - assert "config" in data[0] - assert data[0]["config"]["session_name"] == "dev" - assert data[0]["config"]["windows"][0]["window_name"] == "editor" + # JSON output is now an object with workspaces and global_workspace_dirs + assert isinstance(data, dict) + workspaces = data["workspaces"] + + assert len(workspaces) == 1 + assert "config" in workspaces[0] + assert workspaces[0]["config"]["session_name"] == "dev" + assert workspaces[0]["config"]["windows"][0]["window_name"] == "editor" def test_ls_full_tree_shows_windows( self, @@ -617,7 +630,7 @@ def test_ls_full_flat_shows_windows( output = capsys.readouterr().out - assert "Global workspaces:" in output + assert "Global workspaces (~/.tmuxp):" in output assert "dev" in output assert "code" in output assert "pane 0" in output @@ -649,3 +662,197 @@ def test_ls_full_without_json_no_config_in_output( # Should show tree structure, not raw config keys assert "editor" in output assert "session_name:" not in output # Raw YAML not in output + + +class TestLsGlobalWorkspaceDirs: + """Tests for global workspace directories display in ls command.""" + + def test_ls_shows_global_workspace_dirs_section( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Human output shows global workspace directories section.""" + home = tmp_path / "home" + tmuxp_dir = home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(home) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + monkeypatch.delenv("TMUXP_CONFIGDIR", raising=False) + + (tmuxp_dir / "workspace.yaml").write_text("session_name: test\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls"]) + + output = capsys.readouterr().out + + assert "Global workspace directories:" in output + assert "Legacy: ~/.tmuxp" in output + assert "1 workspace" in output + assert "active" in output + assert "~/.config/tmuxp" in output + assert "not found" in output + + def test_ls_global_header_shows_active_dir( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Global workspaces header shows active directory path.""" + home = tmp_path / "home" + tmuxp_dir = home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(home) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + monkeypatch.delenv("TMUXP_CONFIGDIR", raising=False) + + (tmuxp_dir / "workspace.yaml").write_text("session_name: test\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls"]) + + output = capsys.readouterr().out + + # Header should include the active directory + assert "Global workspaces (~/.tmuxp):" in output + + def test_ls_json_includes_global_workspace_dirs( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + """JSON output includes global_workspace_dirs array.""" + home = tmp_path / "home" + tmuxp_dir = home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(home) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + monkeypatch.delenv("TMUXP_CONFIGDIR", raising=False) + + (tmuxp_dir / "workspace.yaml").write_text("session_name: test\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["ls", "--json"]) + + output = capsys.readouterr().out + data = json.loads(output) + + # JSON should be an object with workspaces and global_workspace_dirs + assert isinstance(data, dict) + assert "workspaces" in data + assert "global_workspace_dirs" in data + + # Check global_workspace_dirs structure + dirs = data["global_workspace_dirs"] + assert isinstance(dirs, list) + assert len(dirs) >= 1 + + for d in dirs: + assert "path" in d + assert "source" in d + assert "exists" in d + assert "workspace_count" in d + assert "active" in d + + # Find the active one + active_dirs = [d for d in dirs if d["active"]] + assert len(active_dirs) == 1 + assert active_dirs[0]["path"] == "~/.tmuxp" + assert active_dirs[0]["exists"] is True + assert active_dirs[0]["workspace_count"] == 1 + + def test_ls_json_empty_still_has_global_workspace_dirs( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + """JSON output with no workspaces still includes global_workspace_dirs.""" + home = tmp_path / "home" + tmuxp_dir = home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) # Empty directory + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(home) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + monkeypatch.delenv("TMUXP_CONFIGDIR", raising=False) + + with contextlib.suppress(SystemExit): + cli.cli(["ls", "--json"]) + + output = capsys.readouterr().out + data = json.loads(output) + + assert "workspaces" in data + assert "global_workspace_dirs" in data + assert len(data["workspaces"]) == 0 + assert len(data["global_workspace_dirs"]) >= 1 + + def test_ls_xdg_takes_precedence_in_header( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + """When XDG dir exists, it shows in header instead of ~/.tmuxp.""" + home = tmp_path / "home" + xdg_tmuxp = home / ".config" / "tmuxp" + xdg_tmuxp.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(home) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + monkeypatch.delenv("TMUXP_CONFIGDIR", raising=False) + + (xdg_tmuxp / "workspace.yaml").write_text("session_name: test\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls"]) + + output = capsys.readouterr().out + + # Header should show XDG path when it's active + assert "Global workspaces (~/.config/tmuxp):" in output + + def test_ls_tree_shows_global_workspace_dirs( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Tree mode also shows global workspace directories section.""" + home = tmp_path / "home" + tmuxp_dir = home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(home) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + monkeypatch.delenv("TMUXP_CONFIGDIR", raising=False) + + (tmuxp_dir / "workspace.yaml").write_text("session_name: test\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls", "--tree"]) + + output = capsys.readouterr().out + + assert "Global workspace directories:" in output + assert "Legacy: ~/.tmuxp" in output + assert "active" in output diff --git a/tests/workspace/test_finder.py b/tests/workspace/test_finder.py index 20aee8703a..dd0b270bb8 100644 --- a/tests/workspace/test_finder.py +++ b/tests/workspace/test_finder.py @@ -13,6 +13,7 @@ from tmuxp.workspace.finders import ( find_workspace_file, get_workspace_dir, + get_workspace_dir_candidates, in_cwd, in_dir, is_pure_name, @@ -357,3 +358,159 @@ def check_cmd(config_arg: str) -> _pytest.capture.CaptureResult[str]: match="workspace-file not found in workspace dir", ): assert "workspace-file not found in workspace dir" in check_cmd("moo").err + + +class GetWorkspaceDirCandidatesFixture(t.NamedTuple): + """Test fixture for get_workspace_dir_candidates().""" + + test_id: str + env_vars: dict[str, str] # Relative to tmp_path + dirs_to_create: list[str] # Relative to tmp_path + workspace_files: dict[str, int] # dir -> count of .yaml files to create + expected_active_suffix: str # Suffix of active dir (e.g., ".tmuxp") + expected_candidates_count: int + + +GET_WORKSPACE_DIR_CANDIDATES_FIXTURES: list[GetWorkspaceDirCandidatesFixture] = [ + GetWorkspaceDirCandidatesFixture( + test_id="default_tmuxp_only", + env_vars={}, + dirs_to_create=["home/.tmuxp"], + workspace_files={"home/.tmuxp": 3}, + expected_active_suffix=".tmuxp", + expected_candidates_count=2, # ~/.config/tmuxp (not found) + ~/.tmuxp + ), + GetWorkspaceDirCandidatesFixture( + test_id="xdg_exists_tmuxp_not", + env_vars={"XDG_CONFIG_HOME": "home/.config"}, + dirs_to_create=["home/.config/tmuxp"], + workspace_files={"home/.config/tmuxp": 2}, + expected_active_suffix="tmuxp", # XDG takes precedence + expected_candidates_count=2, + ), + GetWorkspaceDirCandidatesFixture( + test_id="both_exist_xdg_wins", + env_vars={"XDG_CONFIG_HOME": "home/.config"}, + dirs_to_create=["home/.config/tmuxp", "home/.tmuxp"], + workspace_files={"home/.config/tmuxp": 2, "home/.tmuxp": 5}, + expected_active_suffix="tmuxp", # XDG wins when both exist + expected_candidates_count=2, + ), + GetWorkspaceDirCandidatesFixture( + test_id="custom_configdir", + env_vars={"TMUXP_CONFIGDIR": "custom/workspaces"}, + dirs_to_create=["custom/workspaces", "home/.tmuxp"], + workspace_files={"custom/workspaces": 4}, + expected_active_suffix="workspaces", + expected_candidates_count=3, # custom + ~/.config/tmuxp + ~/.tmuxp + ), + GetWorkspaceDirCandidatesFixture( + test_id="none_exist_fallback", + env_vars={}, + dirs_to_create=[], # No dirs created + workspace_files={}, + expected_active_suffix=".tmuxp", # Falls back to ~/.tmuxp + expected_candidates_count=2, + ), +] + + +@pytest.mark.parametrize( + list(GetWorkspaceDirCandidatesFixture._fields), + GET_WORKSPACE_DIR_CANDIDATES_FIXTURES, + ids=[test.test_id for test in GET_WORKSPACE_DIR_CANDIDATES_FIXTURES], +) +def test_get_workspace_dir_candidates( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + test_id: str, + env_vars: dict[str, str], + dirs_to_create: list[str], + workspace_files: dict[str, int], + expected_active_suffix: str, + expected_candidates_count: int, +) -> None: + """Test get_workspace_dir_candidates() returns correct candidates.""" + # Setup home directory + home = tmp_path / "home" + home.mkdir(parents=True, exist_ok=True) + monkeypatch.setenv("HOME", str(home)) + + # Clear any existing env vars that might interfere + monkeypatch.delenv("TMUXP_CONFIGDIR", raising=False) + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + + # Create directories + for dir_path in dirs_to_create: + (tmp_path / dir_path).mkdir(parents=True, exist_ok=True) + + # Create workspace files + for dir_path, count in workspace_files.items(): + dir_full = tmp_path / dir_path + for i in range(count): + (dir_full / f"workspace{i}.yaml").touch() + + # Set environment variables (resolve relative paths) + for var, path in env_vars.items(): + monkeypatch.setenv(var, str(tmp_path / path)) + + # Get candidates + candidates = get_workspace_dir_candidates() + + # Verify count + assert len(candidates) == expected_candidates_count, ( + f"Expected {expected_candidates_count} candidates, got {len(candidates)}" + ) + + # Verify structure + for candidate in candidates: + assert "path" in candidate + assert "source" in candidate + assert "exists" in candidate + assert "workspace_count" in candidate + assert "active" in candidate + + # Verify exactly one is active + active_candidates = [c for c in candidates if c["active"]] + assert len(active_candidates) == 1, "Expected exactly one active candidate" + + # Verify active suffix + active = active_candidates[0] + assert active["path"].endswith(expected_active_suffix), ( + f"Expected active path to end with '{expected_active_suffix}', " + f"got '{active['path']}'" + ) + + # Verify workspace counts for existing directories + for candidate in candidates: + if candidate["exists"]: + # Find the matching dir in workspace_files by the last path component + candidate_suffix = candidate["path"].split("/")[-1] + for dir_path, expected_count in workspace_files.items(): + if dir_path.endswith(candidate_suffix): + assert candidate["workspace_count"] == expected_count, ( + f"Expected {expected_count} workspaces in {candidate['path']}, " + f"got {candidate['workspace_count']}" + ) + break # Found match, stop checking + + +def test_get_workspace_dir_candidates_uses_private_path( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test that get_workspace_dir_candidates() masks home directory with ~.""" + home = tmp_path / "home" + tmuxp_dir = home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + monkeypatch.setenv("HOME", str(home)) + monkeypatch.delenv("TMUXP_CONFIGDIR", raising=False) + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + + candidates = get_workspace_dir_candidates() + + # All paths should use ~ instead of full home path + for candidate in candidates: + path = candidate["path"] + assert str(home) not in path, f"Path should be masked: {path}" + assert path.startswith("~"), f"Path should start with ~: {path}" From 3455d69fc9fa9d596e6d21996a32568f840eb2a3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Jan 2026 13:02:09 -0600 Subject: [PATCH 53/99] ai(rules[AGENTS]): Add CLI Color Semantics guide (Revision 1) --- AGENTS.md | 62 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 7fef9c6d2e..ecc155adbc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -195,3 +195,65 @@ $ uv run pytest --cov - **QA every edit**: Run formatting and tests before committing - **Minimum Python**: 3.10+ (per pyproject.toml) - **Minimum tmux**: 3.2+ (as per README) + +## CLI Color Semantics (Revision 1, 2026-01-04) + +The CLI uses semantic colors via the `Colors` class in `src/tmuxp/cli/_colors.py`. Colors are chosen based on **hierarchy level** and **semantic meaning**, not just data type. + +### Design Principles + +1. **Structural hierarchy**: Headers > Items > Details +2. **Semantic meaning**: What IS this element? +3. **Visual weight**: What should draw the eye first? +4. **Depth separation**: Parent elements should visually contain children + +Inspired by patterns from **jq** (object keys vs values), **ripgrep** (path/line/match distinction), and **mise/just** (semantic method names). + +### Hierarchy-Based Colors + +| Level | Element Type | Method | Color | Examples | +|-------|--------------|--------|-------|----------| +| **L0** | Section headers | `heading()` | Cyan + bold | "Local workspaces:", "Global workspaces:" | +| **L1** | Primary content | `highlight()` | Magenta + bold | Workspace names (braintree, .tmuxp) | +| **L2** | Supplementary info | `info()` | Cyan | Paths (~/.tmuxp, ~/project/.tmuxp.yaml) | +| **L3** | Metadata/labels | `muted()` | Blue (dim) | Source labels (Legacy:, XDG default:) | + +### Status-Based Colors (Override hierarchy when applicable) + +| Status | Method | Color | Examples | +|--------|--------|-------|----------| +| Success/Active | `success()` | Green | "active", "18 workspaces" | +| Warning | `warning()` | Yellow | Deprecation notices | +| Error | `error()` | Red | Error messages | + +### Example Output + +``` +Local workspaces: ← heading() cyan+bold + .tmuxp ~/work/python/tmuxp/.tmuxp.yaml ← highlight() + info() + +Global workspaces (~/.tmuxp): ← heading() + info() + braintree ← highlight() + cihai ← highlight() + +Global workspace directories: ← heading() + Legacy: ~/.tmuxp (18 workspaces, active) ← muted() + info() + success() + XDG default: ~/.config/tmuxp (not found) ← muted() + info() + muted() +``` + +### Available Methods + +```python +colors = Colors() +colors.heading("Section:") # Cyan + bold (section headers) +colors.highlight("item") # Magenta + bold (primary content) +colors.info("/path/to/file") # Cyan (paths, supplementary info) +colors.muted("label:") # Blue dim (metadata, labels) +colors.success("ok") # Green (success states) +colors.warning("caution") # Yellow (warnings) +colors.error("failed") # Red (errors) +``` + +### Key Rule + +**Never use the same color for adjacent hierarchy levels.** If headers and items are both blue, they blend together. Each level must be visually distinct. From b7ba3ed10ba2adaf36afad80bab32cb0a42fd7fc Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Jan 2026 13:28:54 -0600 Subject: [PATCH 54/99] refactor(cli[search]): Use heading() for section headers Consistency with ls.py - section headers should use heading() (cyan+bold) instead of muted() (blue dim) per Color Semantics Rev 1. --- src/tmuxp/cli/search.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tmuxp/cli/search.py b/src/tmuxp/cli/search.py index ad7c487120..c797e37bcc 100644 --- a/src/tmuxp/cli/search.py +++ b/src/tmuxp/cli/search.py @@ -1011,7 +1011,7 @@ def output_result(result: WorkspaceSearchResult, show_path: bool) -> None: # Output local results first if local_results: - formatter.emit_text(colors.muted("Local workspaces:")) + formatter.emit_text(colors.heading("Local workspaces:")) for result in local_results: output_result(result, show_path=True) @@ -1019,7 +1019,7 @@ def output_result(result: WorkspaceSearchResult, show_path: bool) -> None: if global_results: if local_results: formatter.emit_text("") # Blank line separator - formatter.emit_text(colors.muted("Global workspaces:")) + formatter.emit_text(colors.heading("Global workspaces:")) for result in global_results: output_result(result, show_path=False) From 805224dffce512beee7538bde498480b2a648801 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Jan 2026 13:34:58 -0600 Subject: [PATCH 55/99] refactor(cli): Use format_separator() for visual dividers Replace inline separator strings with colors.format_separator() for consistency with debug-info and other CLI output patterns. Files: freeze.py, import_config.py --- src/tmuxp/cli/freeze.py | 4 +--- src/tmuxp/cli/import_config.py | 3 +-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/tmuxp/cli/freeze.py b/src/tmuxp/cli/freeze.py index b7e19cab09..9b48ebf01e 100644 --- a/src/tmuxp/cli/freeze.py +++ b/src/tmuxp/cli/freeze.py @@ -150,9 +150,7 @@ def command_freeze( if not args.quiet: print( # NOQA: T201 RUF100 - colors.muted( - "---------------------------------------------------------------" - ) + colors.format_separator(63) + "\n" + colors.muted("Freeze does its best to snapshot live tmux sessions.") + "\n", diff --git a/src/tmuxp/cli/import_config.py b/src/tmuxp/cli/import_config.py index ea839dc74c..63c2d24a30 100644 --- a/src/tmuxp/cli/import_config.py +++ b/src/tmuxp/cli/import_config.py @@ -189,10 +189,9 @@ def import_config( else: sys.exit(colors.error("Unknown config format.")) - separator = "---------------------------------------------------------------" tmuxp_echo( new_config - + colors.muted(separator) + + colors.format_separator(63) + "\n" + colors.muted("Configuration import does its best to convert files.") + "\n", From 3bd5de5bc2687abd74c85ef76704607d6965a586 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Jan 2026 13:39:16 -0600 Subject: [PATCH 56/99] fix(cli[utils]): Use PrivatePath for prompt default display The prompt() function now masks home directory in the displayed default value using PrivatePath, showing ~ instead of /home/user. Before: Save to: ~/.tmuxp/session.yaml [/home/user/.tmuxp/session.yaml] After: Save to: ~/.tmuxp/session.yaml [~/.tmuxp/session.yaml] The actual returned value remains the full path for file operations. --- src/tmuxp/cli/utils.py | 7 +++++- tests/cli/test_prompt_colors.py | 38 +++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/tmuxp/cli/utils.py b/src/tmuxp/cli/utils.py index bfb90ae5b9..aecdfc3dbf 100644 --- a/src/tmuxp/cli/utils.py +++ b/src/tmuxp/cli/utils.py @@ -6,6 +6,7 @@ import typing as t from tmuxp import log +from tmuxp._internal.private_path import PrivatePath from ._colors import ( ColorMode, @@ -83,7 +84,11 @@ def prompt( `flask-script license `_. """ colors = Colors(color_mode if color_mode is not None else ColorMode.AUTO) - prompt_ = name + ((default and " " + colors.info(f"[{default}]")) or "") + # Use PrivatePath to mask home directory in displayed default + display_default = str(PrivatePath(default)) if default else None + prompt_ = name + ( + (display_default and " " + colors.info(f"[{display_default}]")) or "" + ) prompt_ += (name.endswith("?") and " ") or ": " while True: rv = input(prompt_) or default diff --git a/tests/cli/test_prompt_colors.py b/tests/cli/test_prompt_colors.py index 90218fe660..0806371584 100644 --- a/tests/cli/test_prompt_colors.py +++ b/tests/cli/test_prompt_colors.py @@ -134,3 +134,41 @@ def validate_path(val: str) -> str: result = prompt("Enter path", value_proc=validate_path) assert result == "/valid/path" + + +def test_prompt_default_uses_private_path( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, +) -> None: + """Verify prompt() masks home directory in default value display. + + The displayed default should use PrivatePath to show ~ instead of + the full home directory path. + """ + import pathlib + + from tmuxp.cli.utils import prompt + + # Create a path under the user's home directory + home = pathlib.Path.home() + test_path = str(home / ".tmuxp" / "session.yaml") + + # Capture what prompt displays + displayed_prompt = None + + def capture_input(prompt_text: str) -> str: + nonlocal displayed_prompt + displayed_prompt = prompt_text + return "" # User presses Enter, accepting default + + monkeypatch.setattr("builtins.input", capture_input) + + result = prompt("Save to", default=test_path) + + # The result should be the original path (for actual saving) + assert result == test_path + + # The displayed prompt should use ~ instead of full home path + assert displayed_prompt is not None + assert "~/.tmuxp/session.yaml" in displayed_prompt + assert str(home) not in displayed_prompt From 7b8329909b73a633b56777c8c43eeb0aa9f605e6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Jan 2026 14:25:31 -0600 Subject: [PATCH 57/99] feat(cli[search]): Include window and pane in default search fields Previously, `tmuxp search gp-libs` would not find workspaces with a window named "gp-libs" because window/pane fields required explicit `window:` or `pane:` prefixes. Now searches all fields by default: - name (workspace filename) - session_name - path - window (window names) - pane (pane shell commands) --- src/tmuxp/cli/search.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tmuxp/cli/search.py b/src/tmuxp/cli/search.py index c797e37bcc..e42927b1f8 100644 --- a/src/tmuxp/cli/search.py +++ b/src/tmuxp/cli/search.py @@ -61,7 +61,7 @@ ) #: Default fields to search when no field prefix is specified -DEFAULT_FIELDS: tuple[str, ...] = ("name", "session_name", "path") +DEFAULT_FIELDS: tuple[str, ...] = ("name", "session_name", "path", "window", "pane") class SearchToken(t.NamedTuple): From 31d64095f2463166aea1b3d75479ed3f27aec7cf Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Jan 2026 17:40:17 -0600 Subject: [PATCH 58/99] feat(cli): Wire up help colorization with factory pattern - Add create_themed_formatter() factory to inject theme into argparse formatters - Help output now colorizes examples when FORCE_COLOR=1 or on TTY - Respects NO_COLOR environment variable per no-color.org standard - Add _theme class attribute to TmuxpHelpFormatter for proper typing Also fixes: - Remove forbidden # doctest: +SKIP from _output.py (AGENTS.md violation) - Update search.py doctests for new DEFAULT_FIELDS with window/pane - Add missing pathlib import to test_prompt_colors.py Tests: Add tests/cli/test_formatter.py with 12 tests for factory and colorization --- src/tmuxp/cli/__init__.py | 26 +++-- src/tmuxp/cli/_formatter.py | 64 +++++++++++ src/tmuxp/cli/_output.py | 5 - src/tmuxp/cli/search.py | 14 +-- tests/cli/test_formatter.py | 186 ++++++++++++++++++++++++++++++++ tests/cli/test_prompt_colors.py | 2 + 6 files changed, 274 insertions(+), 23 deletions(-) create mode 100644 tests/cli/test_formatter.py diff --git a/src/tmuxp/cli/__init__.py b/src/tmuxp/cli/__init__.py index 178f33916b..b05708321f 100644 --- a/src/tmuxp/cli/__init__.py +++ b/src/tmuxp/cli/__init__.py @@ -17,7 +17,7 @@ from tmuxp.log import setup_logger from ._colors import build_description -from ._formatter import TmuxpHelpFormatter +from ._formatter import TmuxpHelpFormatter, create_themed_formatter from .convert import CONVERT_DESCRIPTION, command_convert, create_convert_subparser from .debug_info import ( DEBUG_INFO_DESCRIPTION, @@ -162,10 +162,14 @@ def create_parser() -> argparse.ArgumentParser: """Create CLI :class:`argparse.ArgumentParser` for tmuxp.""" + # Use factory to create themed formatter with auto-detected color mode + # This respects NO_COLOR, FORCE_COLOR env vars and TTY detection + formatter_class = create_themed_formatter() + parser = argparse.ArgumentParser( prog="tmuxp", description=CLI_DESCRIPTION, - formatter_class=TmuxpHelpFormatter, + formatter_class=formatter_class, ) parser.add_argument( "--version", @@ -192,21 +196,21 @@ def create_parser() -> argparse.ArgumentParser: "load", help="load tmuxp workspaces", description=LOAD_DESCRIPTION, - formatter_class=TmuxpHelpFormatter, + formatter_class=formatter_class, ) create_load_subparser(load_parser) shell_parser = subparsers.add_parser( "shell", help="launch python shell for tmux server, session, window and pane", description=SHELL_DESCRIPTION, - formatter_class=TmuxpHelpFormatter, + formatter_class=formatter_class, ) create_shell_subparser(shell_parser) import_parser = subparsers.add_parser( "import", help="import workspaces from teamocil and tmuxinator.", description=IMPORT_DESCRIPTION, - formatter_class=TmuxpHelpFormatter, + formatter_class=formatter_class, ) create_import_subparser(import_parser) @@ -214,7 +218,7 @@ def create_parser() -> argparse.ArgumentParser: "convert", help="convert workspace files between yaml and json.", description=CONVERT_DESCRIPTION, - formatter_class=TmuxpHelpFormatter, + formatter_class=formatter_class, ) create_convert_subparser(convert_parser) @@ -222,7 +226,7 @@ def create_parser() -> argparse.ArgumentParser: "debug-info", help="print out all diagnostic info", description=DEBUG_INFO_DESCRIPTION, - formatter_class=TmuxpHelpFormatter, + formatter_class=formatter_class, ) create_debug_info_subparser(debug_info_parser) @@ -230,7 +234,7 @@ def create_parser() -> argparse.ArgumentParser: "ls", help="list workspaces in tmuxp directory", description=LS_DESCRIPTION, - formatter_class=TmuxpHelpFormatter, + formatter_class=formatter_class, ) create_ls_subparser(ls_parser) @@ -238,7 +242,7 @@ def create_parser() -> argparse.ArgumentParser: "search", help="search workspace files by name, session, path, or content", description=SEARCH_DESCRIPTION, - formatter_class=TmuxpHelpFormatter, + formatter_class=formatter_class, ) create_search_subparser(search_parser) @@ -246,7 +250,7 @@ def create_parser() -> argparse.ArgumentParser: "edit", help="run $EDITOR on workspace file", description=EDIT_DESCRIPTION, - formatter_class=TmuxpHelpFormatter, + formatter_class=formatter_class, ) create_edit_subparser(edit_parser) @@ -254,7 +258,7 @@ def create_parser() -> argparse.ArgumentParser: "freeze", help="freeze a live tmux session to a tmuxp workspace file", description=FREEZE_DESCRIPTION, - formatter_class=TmuxpHelpFormatter, + formatter_class=formatter_class, ) create_freeze_subparser(freeze_parser) diff --git a/src/tmuxp/cli/_formatter.py b/src/tmuxp/cli/_formatter.py index b58b8ec657..418e213c8a 100644 --- a/src/tmuxp/cli/_formatter.py +++ b/src/tmuxp/cli/_formatter.py @@ -80,6 +80,9 @@ class TmuxpHelpFormatter(argparse.RawDescriptionHelpFormatter): <...TmuxpHelpFormatter object at ...> """ + # Theme for colorization, set by create_themed_formatter() or externally + _theme: HelpTheme | None = None + def _fill_text(self, text: str, width: int, indent: str) -> str: """Fill text, colorizing examples sections if theme is available. @@ -301,3 +304,64 @@ def from_colors(cls, colors: t.Any) -> HelpTheme: heading=style("", fg="blue").rstrip("\033[0m"), reset="\033[0m", ) + + +def create_themed_formatter( + colors: t.Any | None = None, +) -> type[TmuxpHelpFormatter]: + """Create a help formatter class with theme bound. + + This factory creates a formatter subclass with the theme injected, + allowing colorized help output without modifying argparse internals. + + When no colors argument is provided, uses AUTO mode which respects + NO_COLOR, FORCE_COLOR environment variables and TTY detection. + + Parameters + ---------- + colors : Colors | None + Colors instance for styling. If None, uses ColorMode.AUTO. + + Returns + ------- + type[TmuxpHelpFormatter] + Formatter class with theme bound. + + Examples + -------- + >>> from tmuxp.cli._colors import ColorMode, Colors + >>> from tmuxp.cli._formatter import create_themed_formatter, HelpTheme + + With explicit colors enabled: + + >>> colors = Colors(ColorMode.ALWAYS) + >>> formatter_cls = create_themed_formatter(colors) + >>> formatter = formatter_cls("test") + >>> formatter._theme is not None + True + + With colors disabled: + + >>> colors = Colors(ColorMode.NEVER) + >>> formatter_cls = create_themed_formatter(colors) + >>> formatter = formatter_cls("test") + >>> formatter._theme is None + True + """ + # Import here to avoid circular import at module load + from tmuxp.cli._colors import ColorMode, Colors + + if colors is None: + colors = Colors(ColorMode.AUTO) + + # Create theme if colors are enabled, None otherwise + theme = HelpTheme.from_colors(colors) if colors._enabled else None + + class ThemedTmuxpHelpFormatter(TmuxpHelpFormatter): + """TmuxpHelpFormatter with theme pre-configured.""" + + def __init__(self, prog: str, **kwargs: t.Any) -> None: + super().__init__(prog, **kwargs) + self._theme = theme + + return ThemedTmuxpHelpFormatter diff --git a/src/tmuxp/cli/_output.py b/src/tmuxp/cli/_output.py index d4a89d44c5..7ac8df92ef 100644 --- a/src/tmuxp/cli/_output.py +++ b/src/tmuxp/cli/_output.py @@ -6,11 +6,6 @@ -------- >>> from tmuxp.cli._output import OutputMode, OutputFormatter, get_output_mode -Basic usage with human mode (default): - ->>> formatter = OutputFormatter(OutputMode.HUMAN) ->>> formatter.emit_text("Hello, world!") # doctest: +SKIP - Get output mode from flags: >>> get_output_mode(json_flag=False, ndjson_flag=False) diff --git a/src/tmuxp/cli/search.py b/src/tmuxp/cli/search.py index e42927b1f8..6b10bee97d 100644 --- a/src/tmuxp/cli/search.py +++ b/src/tmuxp/cli/search.py @@ -18,7 +18,7 @@ >>> tokens[0] SearchToken(fields=('name',), pattern='dev') >>> tokens[1] -SearchToken(fields=('name', 'session_name', 'path'), pattern='editor') +SearchToken(fields=('name', 'session_name', 'path', 'window', 'pane'), pattern='editor') """ from __future__ import annotations @@ -123,7 +123,7 @@ class InvalidFieldError(ValueError): Examples -------- - >>> raise InvalidFieldError("invalid") + >>> raise InvalidFieldError("invalid") # doctest: +ELLIPSIS Traceback (most recent call last): ... tmuxp.cli.search.InvalidFieldError: Unknown search field: 'invalid'. ... @@ -156,7 +156,7 @@ def normalize_fields(fields: list[str] | None) -> tuple[str, ...]: Examples -------- >>> normalize_fields(None) - ('name', 'session_name', 'path') + ('name', 'session_name', 'path', 'window', 'pane') >>> normalize_fields(["s", "n"]) ('session_name', 'name') @@ -164,7 +164,7 @@ def normalize_fields(fields: list[str] | None) -> tuple[str, ...]: >>> normalize_fields(["session_name", "path"]) ('session_name', 'path') - >>> normalize_fields(["invalid"]) + >>> normalize_fields(["invalid"]) # doctest: +ELLIPSIS Traceback (most recent call last): ... tmuxp.cli.search.InvalidFieldError: Unknown search field: 'invalid'. ... @@ -260,7 +260,7 @@ def parse_query_terms( -------- >>> tokens = parse_query_terms(["dev"]) >>> tokens[0].fields - ('name', 'session_name', 'path') + ('name', 'session_name', 'path', 'window', 'pane') >>> tokens[0].pattern 'dev' @@ -274,7 +274,7 @@ def parse_query_terms( >>> tokens[0].fields ('window',) >>> tokens[1].fields - ('name', 'session_name', 'path') + ('name', 'session_name', 'path', 'window', 'pane') Unknown prefixes are treated as literal patterns (allows URLs, etc.): @@ -282,7 +282,7 @@ def parse_query_terms( >>> tokens[0].pattern 'http://example.com' >>> tokens[0].fields # Searches default fields - ('name', 'session_name', 'path') + ('name', 'session_name', 'path', 'window', 'pane') """ result: list[SearchToken] = [] diff --git a/tests/cli/test_formatter.py b/tests/cli/test_formatter.py new file mode 100644 index 0000000000..ecd16dbb17 --- /dev/null +++ b/tests/cli/test_formatter.py @@ -0,0 +1,186 @@ +"""Tests for TmuxpHelpFormatter and themed formatter factory.""" + +from __future__ import annotations + +import argparse + +import pytest + +from tmuxp.cli._colors import ColorMode, Colors +from tmuxp.cli._formatter import ( + HelpTheme, + TmuxpHelpFormatter, + create_themed_formatter, +) + + +class TestCreateThemedFormatter: + """Tests for create_themed_formatter factory.""" + + def test_factory_returns_formatter_subclass(self) -> None: + """Factory returns a TmuxpHelpFormatter subclass.""" + formatter_cls = create_themed_formatter() + assert issubclass(formatter_cls, TmuxpHelpFormatter) + + def test_factory_with_colors_enabled( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Formatter has theme when colors enabled.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + formatter_cls = create_themed_formatter(colors) + formatter = formatter_cls("test") + + assert formatter._theme is not None + assert formatter._theme.prog != "" # Has color codes + + def test_factory_with_colors_disabled(self) -> None: + """Formatter has no theme when colors disabled.""" + colors = Colors(ColorMode.NEVER) + formatter_cls = create_themed_formatter(colors) + formatter = formatter_cls("test") + + assert formatter._theme is None + + def test_factory_auto_mode_respects_no_color( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Auto mode respects NO_COLOR environment variable.""" + monkeypatch.setenv("NO_COLOR", "1") + formatter_cls = create_themed_formatter() + formatter = formatter_cls("test") + + assert formatter._theme is None + + def test_factory_auto_mode_respects_force_color( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Auto mode respects FORCE_COLOR environment variable.""" + monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.setenv("FORCE_COLOR", "1") + formatter_cls = create_themed_formatter() + formatter = formatter_cls("test") + + assert formatter._theme is not None + + +class TestTmuxpHelpFormatterColorization: + """Tests for help text colorization.""" + + def test_fill_text_with_theme_colorizes_examples( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Examples section is colorized when theme is set.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + formatter_cls = create_themed_formatter(colors) + formatter = formatter_cls("tmuxp") + + text = "Examples:\n tmuxp load myproject" + result = formatter._fill_text(text, 80, "") + + # Should contain ANSI escape codes + assert "\033[" in result + assert "tmuxp" in result + assert "load" in result + + def test_fill_text_without_theme_plain_text(self) -> None: + """Examples section is plain text when no theme.""" + colors = Colors(ColorMode.NEVER) + formatter_cls = create_themed_formatter(colors) + formatter = formatter_cls("tmuxp") + + text = "Examples:\n tmuxp load myproject" + result = formatter._fill_text(text, 80, "") + + # Should NOT contain ANSI escape codes + assert "\033[" not in result + assert "tmuxp load myproject" in result + + +class TestHelpOutputIntegration: + """Integration tests for help output colorization.""" + + def test_parser_help_respects_no_color( + self, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Parser --help output is plain when NO_COLOR set.""" + monkeypatch.setenv("NO_COLOR", "1") + monkeypatch.setenv("COLUMNS", "100") + + formatter_cls = create_themed_formatter() + parser = argparse.ArgumentParser( + prog="test", + description="Examples:\n test command", + formatter_class=formatter_cls, + ) + + with pytest.raises(SystemExit): + parser.parse_args(["--help"]) + + captured = capsys.readouterr() + assert "\033[" not in captured.out + + def test_parser_help_colorized_with_force_color( + self, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Parser --help output is colorized when FORCE_COLOR set.""" + monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.setenv("FORCE_COLOR", "1") + monkeypatch.setenv("COLUMNS", "100") + + formatter_cls = create_themed_formatter() + parser = argparse.ArgumentParser( + prog="test", + description="Examples:\n test command", + formatter_class=formatter_cls, + ) + + with pytest.raises(SystemExit): + parser.parse_args(["--help"]) + + captured = capsys.readouterr() + assert "\033[" in captured.out + + +class TestHelpTheme: + """Tests for HelpTheme creation.""" + + def test_from_colors_with_none_returns_empty_theme(self) -> None: + """HelpTheme.from_colors(None) returns empty theme.""" + theme = HelpTheme.from_colors(None) + + assert theme.prog == "" + assert theme.action == "" + assert theme.reset == "" + + def test_from_colors_disabled_returns_empty_theme(self) -> None: + """HelpTheme.from_colors with disabled colors returns empty theme.""" + colors = Colors(ColorMode.NEVER) + theme = HelpTheme.from_colors(colors) + + assert theme.prog == "" + assert theme.action == "" + assert theme.reset == "" + + def test_from_colors_enabled_returns_colored_theme( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """HelpTheme.from_colors with enabled colors returns colored theme.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + theme = HelpTheme.from_colors(colors) + + # Should have ANSI codes + assert "\033[" in theme.prog + assert "\033[" in theme.action + assert theme.reset == "\033[0m" diff --git a/tests/cli/test_prompt_colors.py b/tests/cli/test_prompt_colors.py index 0806371584..1013626ea0 100644 --- a/tests/cli/test_prompt_colors.py +++ b/tests/cli/test_prompt_colors.py @@ -2,6 +2,8 @@ from __future__ import annotations +import pathlib + import pytest from tmuxp.cli._colors import ColorMode, Colors From 72434c027c1362b6c7bd161db77599b18a9ee98f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 5 Jan 2026 04:07:05 -0600 Subject: [PATCH 59/99] fix(cli[_formatter]): Use removesuffix() instead of rstrip() for ANSI codes rstrip("\033[0m") strips individual characters (\, 0, 3, [, m), not the string as a suffix. This corrupted ANSI sequences, leaving broken codes like '\x1b[1' instead of '\x1b[1m'. removesuffix() correctly removes the exact suffix string. --- src/tmuxp/cli/_formatter.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/tmuxp/cli/_formatter.py b/src/tmuxp/cli/_formatter.py index 418e213c8a..4eebaeb919 100644 --- a/src/tmuxp/cli/_formatter.py +++ b/src/tmuxp/cli/_formatter.py @@ -296,12 +296,12 @@ def from_colors(cls, colors: t.Any) -> HelpTheme: from tmuxp.cli._colors import style return cls( - prog=style("", fg="magenta", bold=True).rstrip("\033[0m"), - action=style("", fg="cyan").rstrip("\033[0m"), - long_option=style("", fg="green").rstrip("\033[0m"), - short_option=style("", fg="green").rstrip("\033[0m"), - label=style("", fg="yellow").rstrip("\033[0m"), - heading=style("", fg="blue").rstrip("\033[0m"), + prog=style("", fg="magenta", bold=True).removesuffix("\033[0m"), + action=style("", fg="cyan").removesuffix("\033[0m"), + long_option=style("", fg="green").removesuffix("\033[0m"), + short_option=style("", fg="green").removesuffix("\033[0m"), + label=style("", fg="yellow").removesuffix("\033[0m"), + heading=style("", fg="blue").removesuffix("\033[0m"), reset="\033[0m", ) From f30b63aa17e9d44fed626d38001830ca11b11b99 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 5 Jan 2026 04:07:12 -0600 Subject: [PATCH 60/99] fix(cli[debug_info]): Guard empty strings in _private() PrivatePath("") returns "." (current directory), which would show "shell: ." in debug-info output when SHELL env var is unset/empty. Now empty strings return "" like None does. --- src/tmuxp/cli/debug_info.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/tmuxp/cli/debug_info.py b/src/tmuxp/cli/debug_info.py index ffed3980dd..427f30a2da 100644 --- a/src/tmuxp/cli/debug_info.py +++ b/src/tmuxp/cli/debug_info.py @@ -84,10 +84,12 @@ def _private(path: pathlib.Path | str | None) -> str: -------- >>> _private(None) '' + >>> _private('') + '' >>> _private('/usr/bin/tmux') '/usr/bin/tmux' """ - if path is None: + if path is None or path == "": return "" return str(PrivatePath(path)) From eac27b82c4597892dcdc76fc637ae592f8a21ccf Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 10 Jan 2026 06:04:31 -0600 Subject: [PATCH 61/99] perf(tests[help_examples]): Replace subprocess with in-process argparse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add _get_help_text() helper that accesses argparse directly instead of spawning subprocesses. This eliminates Python startup and module import overhead (~130ms per call). Results: test_help_examples.py 3.16s → 0.51s (6x faster), CLI tests total ~10s → ~5.9s (40% faster). --- tests/cli/test_help_examples.py | 145 +++++++++++++------------------- 1 file changed, 60 insertions(+), 85 deletions(-) diff --git a/tests/cli/test_help_examples.py b/tests/cli/test_help_examples.py index 88234e6232..4a7225b578 100644 --- a/tests/cli/test_help_examples.py +++ b/tests/cli/test_help_examples.py @@ -2,11 +2,43 @@ from __future__ import annotations +import argparse import re import subprocess import pytest +from tmuxp.cli import create_parser + + +def _get_help_text(subcommand: str | None = None) -> str: + """Get CLI help text without spawning subprocess. + + Parameters + ---------- + subcommand : str | None + Subcommand name, or None for main help. + + Returns + ------- + str + The formatted help text. + """ + parser = create_parser() + if subcommand is None: + return parser.format_help() + + # Access subparser via _subparsers._group_actions + subparsers = parser._subparsers + if subparsers is not None: + for action in subparsers._group_actions: + if isinstance(action, argparse._SubParsersAction): + choices = action.choices + if choices is not None and subcommand in choices: + return str(choices[subcommand].format_help()) + + return parser.format_help() + def extract_examples_from_help(help_text: str) -> list[str]: r"""Extract example commands from help text. @@ -48,25 +80,15 @@ def extract_examples_from_help(help_text: str) -> list[str]: def test_main_help_has_examples() -> None: """Main --help should have at least one example.""" - result = subprocess.run( - ["tmuxp", "--help"], - capture_output=True, - text=True, - check=True, - ) - examples = extract_examples_from_help(result.stdout) + help_text = _get_help_text() + examples = extract_examples_from_help(help_text) assert len(examples) > 0, "Main --help should have at least one example" def test_main_help_examples_are_valid_subcommands() -> None: """All examples in main --help should reference valid subcommands.""" - result = subprocess.run( - ["tmuxp", "--help"], - capture_output=True, - text=True, - check=True, - ) - examples = extract_examples_from_help(result.stdout) + help_text = _get_help_text() + examples = extract_examples_from_help(help_text) # Extract valid subcommands from help output valid_subcommands = { @@ -106,25 +128,15 @@ def test_main_help_examples_are_valid_subcommands() -> None: ) def test_subcommand_help_has_examples(subcommand: str) -> None: """Each subcommand --help should have at least one example.""" - result = subprocess.run( - ["tmuxp", subcommand, "--help"], - capture_output=True, - text=True, - check=True, - ) - examples = extract_examples_from_help(result.stdout) + help_text = _get_help_text(subcommand) + examples = extract_examples_from_help(help_text) assert len(examples) > 0, f"{subcommand} --help should have at least one example" def test_load_subcommand_examples_are_valid() -> None: """Load subcommand examples should have valid flags.""" - result = subprocess.run( - ["tmuxp", "load", "--help"], - capture_output=True, - text=True, - check=True, - ) - examples = extract_examples_from_help(result.stdout) + help_text = _get_help_text("load") + examples = extract_examples_from_help(help_text) # Verify each example has valid structure for example in examples: @@ -133,13 +145,8 @@ def test_load_subcommand_examples_are_valid() -> None: def test_freeze_subcommand_examples_are_valid() -> None: """Freeze subcommand examples should have valid flags.""" - result = subprocess.run( - ["tmuxp", "freeze", "--help"], - capture_output=True, - text=True, - check=True, - ) - examples = extract_examples_from_help(result.stdout) + help_text = _get_help_text("freeze") + examples = extract_examples_from_help(help_text) # Verify each example has valid structure for example in examples: @@ -148,13 +155,8 @@ def test_freeze_subcommand_examples_are_valid() -> None: def test_shell_subcommand_examples_are_valid() -> None: """Shell subcommand examples should have valid flags.""" - result = subprocess.run( - ["tmuxp", "shell", "--help"], - capture_output=True, - text=True, - check=True, - ) - examples = extract_examples_from_help(result.stdout) + help_text = _get_help_text("shell") + examples = extract_examples_from_help(help_text) # Verify each example has valid structure for example in examples: @@ -163,13 +165,8 @@ def test_shell_subcommand_examples_are_valid() -> None: def test_convert_subcommand_examples_are_valid() -> None: """Convert subcommand examples should have valid flags.""" - result = subprocess.run( - ["tmuxp", "convert", "--help"], - capture_output=True, - text=True, - check=True, - ) - examples = extract_examples_from_help(result.stdout) + help_text = _get_help_text("convert") + examples = extract_examples_from_help(help_text) # Verify each example has valid structure for example in examples: @@ -178,13 +175,8 @@ def test_convert_subcommand_examples_are_valid() -> None: def test_import_subcommand_examples_are_valid() -> None: """Import subcommand examples should have valid flags.""" - result = subprocess.run( - ["tmuxp", "import", "--help"], - capture_output=True, - text=True, - check=True, - ) - examples = extract_examples_from_help(result.stdout) + help_text = _get_help_text("import") + examples = extract_examples_from_help(help_text) # Verify each example has valid structure for example in examples: @@ -193,13 +185,8 @@ def test_import_subcommand_examples_are_valid() -> None: def test_edit_subcommand_examples_are_valid() -> None: """Edit subcommand examples should have valid flags.""" - result = subprocess.run( - ["tmuxp", "edit", "--help"], - capture_output=True, - text=True, - check=True, - ) - examples = extract_examples_from_help(result.stdout) + help_text = _get_help_text("edit") + examples = extract_examples_from_help(help_text) # Verify each example has valid structure for example in examples: @@ -208,13 +195,8 @@ def test_edit_subcommand_examples_are_valid() -> None: def test_ls_subcommand_examples_are_valid() -> None: """Ls subcommand examples should have valid flags.""" - result = subprocess.run( - ["tmuxp", "ls", "--help"], - capture_output=True, - text=True, - check=True, - ) - examples = extract_examples_from_help(result.stdout) + help_text = _get_help_text("ls") + examples = extract_examples_from_help(help_text) # Verify each example has valid structure for example in examples: @@ -223,13 +205,8 @@ def test_ls_subcommand_examples_are_valid() -> None: def test_debug_info_subcommand_examples_are_valid() -> None: """Debug-info subcommand examples should have valid flags.""" - result = subprocess.run( - ["tmuxp", "debug-info", "--help"], - capture_output=True, - text=True, - check=True, - ) - examples = extract_examples_from_help(result.stdout) + help_text = _get_help_text("debug-info") + examples = extract_examples_from_help(help_text) # Verify each example has valid structure for example in examples: @@ -238,13 +215,8 @@ def test_debug_info_subcommand_examples_are_valid() -> None: def test_search_subcommand_examples_are_valid() -> None: """Search subcommand examples should have valid flags.""" - result = subprocess.run( - ["tmuxp", "search", "--help"], - capture_output=True, - text=True, - check=True, - ) - examples = extract_examples_from_help(result.stdout) + help_text = _get_help_text("search") + examples = extract_examples_from_help(help_text) # Verify each example has valid structure for example in examples: @@ -252,7 +224,10 @@ def test_search_subcommand_examples_are_valid() -> None: def test_search_no_args_shows_help() -> None: - """Running 'tmuxp search' with no args shows help.""" + """Running 'tmuxp search' with no args shows help. + + Note: This test uses subprocess to verify actual CLI behavior and exit code. + """ result = subprocess.run( ["tmuxp", "search"], capture_output=True, From 338c750c8264cd5442c43994d5b8d11426025793 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 10 Jan 2026 07:03:38 -0600 Subject: [PATCH 62/99] fix(cli[utils]): Add feedback for invalid input in prompt_choices() When users enter invalid choices, the function now displays a warning message showing the invalid input and listing valid options before re-prompting. --- src/tmuxp/cli/utils.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/tmuxp/cli/utils.py b/src/tmuxp/cli/utils.py index aecdfc3dbf..10aff68b97 100644 --- a/src/tmuxp/cli/utils.py +++ b/src/tmuxp/cli/utils.py @@ -236,3 +236,7 @@ def prompt_choices( return None if rv in choices_: return rv + print( + colors.warning(f"Invalid choice '{rv}'. ") + + f"Please choose from: {', '.join(choices_)}" + ) From 842ce686718d676155e81cfbbac3c5ffc2b4dd27 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 10 Jan 2026 07:05:15 -0600 Subject: [PATCH 63/99] fix(cli[ls]): Use tmp_path fixture instead of tempfile in doctest Follow CLAUDE.md testing guidelines: use pytest's tmp_path fixture instead of Python's tempfile module for temporary file handling. --- src/tmuxp/cli/ls.py | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/src/tmuxp/cli/ls.py b/src/tmuxp/cli/ls.py index 7f0118540a..45dfec14b4 100644 --- a/src/tmuxp/cli/ls.py +++ b/src/tmuxp/cli/ls.py @@ -194,30 +194,24 @@ def _get_workspace_info( Examples -------- - >>> import tempfile - >>> import pathlib >>> content = "session_name: test-session" + chr(10) + "windows: []" - >>> with tempfile.NamedTemporaryFile( - ... suffix='.yaml', delete=False, mode='w' - ... ) as f: - ... _ = f.write(content) - ... temp_path = pathlib.Path(f.name) - >>> info = _get_workspace_info(temp_path) + >>> yaml_file = tmp_path / "test.yaml" + >>> _ = yaml_file.write_text(content) + >>> info = _get_workspace_info(yaml_file) >>> info['session_name'] 'test-session' >>> info['format'] 'yaml' >>> info['source'] 'global' - >>> info_local = _get_workspace_info(temp_path, source="local") + >>> info_local = _get_workspace_info(yaml_file, source="local") >>> info_local['source'] 'local' - >>> info_full = _get_workspace_info(temp_path, include_config=True) + >>> info_full = _get_workspace_info(yaml_file, include_config=True) >>> 'config' in info_full True >>> info_full['config']['session_name'] 'test-session' - >>> temp_path.unlink() """ stat = filepath.stat() ext = filepath.suffix.lower() From c11ab5e7df24cb64b38c52b1155c143819c8bc55 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 11 Jan 2026 04:40:15 -0600 Subject: [PATCH 64/99] refactor(cli/_colors): Add dim parameter to _colorize() method Add dim parameter to _colorize() for potential future use. Update muted() docstring to remove dim reference since dim styling is too dark on black terminal backgrounds. --- src/tmuxp/cli/_colors.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/tmuxp/cli/_colors.py b/src/tmuxp/cli/_colors.py index 840838e429..bb3fd7ae7f 100644 --- a/src/tmuxp/cli/_colors.py +++ b/src/tmuxp/cli/_colors.py @@ -180,7 +180,9 @@ def _should_enable(self) -> bool: return sys.stdout.isatty() - def _colorize(self, text: str, fg: str, bold: bool = False) -> str: + def _colorize( + self, text: str, fg: str, bold: bool = False, dim: bool = False + ) -> str: """Apply color using style() function. Parameters @@ -191,6 +193,8 @@ def _colorize(self, text: str, fg: str, bold: bool = False) -> str: Foreground color name (e.g., "green", "red"). bold : bool Whether to apply bold style. Default is False. + dim : bool + Whether to apply dim/faint style. Default is False. Returns ------- @@ -212,7 +216,7 @@ def _colorize(self, text: str, fg: str, bold: bool = False) -> str: 'test' """ if self._enabled: - return style(text, fg=fg, bold=bold) + return style(text, fg=fg, bold=bold, dim=dim) return text def success(self, text: str, bold: bool = False) -> str: @@ -331,7 +335,7 @@ def highlight(self, text: str, bold: bool = True) -> str: return self._colorize(text, self.HIGHLIGHT, bold) def muted(self, text: str) -> str: - """Format text as muted (blue, never bold). + """Format text as muted (blue). Parameters ---------- From 50c0360d530f359a3dae8ca2dc214ffbbddc0f1a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 11 Jan 2026 04:40:26 -0600 Subject: [PATCH 65/99] ai(rules[AGENTS]): Add terminal color visibility guidelines - Remove "(dim)" from muted() color spec - too dark on black backgrounds - Add key rules: avoid dim styling, don't rely on bold alone --- AGENTS.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ecc155adbc..dee1d9abe5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -216,7 +216,7 @@ Inspired by patterns from **jq** (object keys vs values), **ripgrep** (path/line | **L0** | Section headers | `heading()` | Cyan + bold | "Local workspaces:", "Global workspaces:" | | **L1** | Primary content | `highlight()` | Magenta + bold | Workspace names (braintree, .tmuxp) | | **L2** | Supplementary info | `info()` | Cyan | Paths (~/.tmuxp, ~/project/.tmuxp.yaml) | -| **L3** | Metadata/labels | `muted()` | Blue (dim) | Source labels (Legacy:, XDG default:) | +| **L3** | Metadata/labels | `muted()` | Blue | Source labels (Legacy:, XDG default:) | ### Status-Based Colors (Override hierarchy when applicable) @@ -248,12 +248,16 @@ colors = Colors() colors.heading("Section:") # Cyan + bold (section headers) colors.highlight("item") # Magenta + bold (primary content) colors.info("/path/to/file") # Cyan (paths, supplementary info) -colors.muted("label:") # Blue dim (metadata, labels) +colors.muted("label:") # Blue (metadata, labels) colors.success("ok") # Green (success states) colors.warning("caution") # Yellow (warnings) colors.error("failed") # Red (errors) ``` -### Key Rule +### Key Rules **Never use the same color for adjacent hierarchy levels.** If headers and items are both blue, they blend together. Each level must be visually distinct. + +**Avoid dim/faint styling.** The ANSI dim attribute (`\x1b[2m`) is too dark to read on black terminal backgrounds. This includes both standard and bright color variants with dim. + +**Bold may not render distinctly.** Some terminal/font combinations don't differentiate bold from normal weight. Don't rely on bold alone for visual distinction - pair it with color differences. From bc7fc9b22e386df1e7390272d0e5c2baa0f8918f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 10 Jan 2026 08:09:07 -0600 Subject: [PATCH 66/99] perf(conftest): Only load tmux fixtures for doctests that need them Add DOCTEST_NEEDS_TMUX set to conditionally load expensive tmux server/session fixtures. Only tmuxp.workspace.builder actually uses them in doctests - all CLI doctests were getting unnecessary overhead. Before: 26 doctests in _colors.py = 11.11s After: 26 doctests in _colors.py = 0.07s (158x faster) --- conftest.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/conftest.py b/conftest.py index 05da66e402..0cd35869ca 100644 --- a/conftest.py +++ b/conftest.py @@ -98,6 +98,12 @@ def socket_name(request: pytest.FixtureRequest) -> str: return f"tmuxp_test{next(namer)}" +# Modules that actually need tmux fixtures in their doctests +DOCTEST_NEEDS_TMUX = { + "tmuxp.workspace.builder", +} + + @pytest.fixture(autouse=True) def add_doctest_fixtures( request: pytest.FixtureRequest, @@ -106,12 +112,17 @@ def add_doctest_fixtures( monkeypatch: pytest.MonkeyPatch, ) -> None: """Harness pytest fixtures to doctests namespace.""" - if isinstance(request._pyfuncitem, DoctestItem) and shutil.which("tmux"): - doctest_namespace["server"] = request.getfixturevalue("server") - session: Session = request.getfixturevalue("session") - doctest_namespace["session"] = session - doctest_namespace["window"] = session.active_window - doctest_namespace["pane"] = session.active_pane + if isinstance(request._pyfuncitem, DoctestItem): + # Always provide lightweight fixtures doctest_namespace["test_utils"] = test_utils doctest_namespace["tmp_path"] = tmp_path doctest_namespace["monkeypatch"] = monkeypatch + + # Only load expensive tmux fixtures for modules that need them + module_name = request._pyfuncitem.dtest.globs.get("__name__", "") + if module_name in DOCTEST_NEEDS_TMUX and shutil.which("tmux"): + doctest_namespace["server"] = request.getfixturevalue("server") + session: Session = request.getfixturevalue("session") + doctest_namespace["session"] = session + doctest_namespace["window"] = session.active_window + doctest_namespace["pane"] = session.active_pane From bf25f42de3431960d46550d958b1438afa30368c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 10 Jan 2026 08:46:57 -0600 Subject: [PATCH 67/99] docs(CHANGES): Add global workspace directories section to ls features --- CHANGES | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGES b/CHANGES index 8685728623..1b0e43cc10 100644 --- a/CHANGES +++ b/CHANGES @@ -64,6 +64,7 @@ New output options for `tmuxp ls`: - `--json` / `--ndjson`: Machine-readable output for automation and piping to `jq` - Local workspace discovery from current directory and parents - Source field distinguishes "local" vs "global" workspaces +- "Global workspace directories" section shows XDG vs legacy paths with status #### JSON Output for debug-info (#1006) From c1b318b6427230154d27af407241ceb43cce76b2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 10 Jan 2026 09:09:27 -0600 Subject: [PATCH 68/99] refactor(cli[ls]): Extract _render_global_workspace_dirs() helper DRY: Replace 3 duplicate code blocks with single helper function. Reduces ls.py by 25 lines while maintaining identical behavior. --- src/tmuxp/cli/ls.py | 115 +++++++++++++++++--------------------------- 1 file changed, 45 insertions(+), 70 deletions(-) diff --git a/src/tmuxp/cli/ls.py b/src/tmuxp/cli/ls.py index 45dfec14b4..e07bca4341 100644 --- a/src/tmuxp/cli/ls.py +++ b/src/tmuxp/cli/ls.py @@ -330,6 +330,48 @@ def _render_config_tree(config: dict[str, t.Any], colors: Colors) -> list[str]: return lines +def _render_global_workspace_dirs( + formatter: OutputFormatter, + colors: Colors, + global_dir_candidates: list[dict[str, t.Any]], +) -> None: + """Render global workspace directories section. + + Parameters + ---------- + formatter : OutputFormatter + Output formatter. + colors : Colors + Color manager. + global_dir_candidates : list[dict[str, Any]] + List of global workspace directory candidates with metadata. + """ + formatter.emit_text("") + formatter.emit_text(colors.heading("Global workspace directories:")) + for candidate in global_dir_candidates: + path = candidate["path"] + source = candidate.get("source", "") + source_prefix = f"{source}: " if source else "" + if candidate["exists"]: + count = candidate["workspace_count"] + status = f"{count} workspace{'s' if count != 1 else ''}" + if candidate["active"]: + status += ", active" + formatter.emit_text( + f" {colors.muted(source_prefix)}{colors.info(path)} " + f"({colors.success(status)})" + ) + else: + formatter.emit_text( + f" {colors.muted(source_prefix)}{colors.info(path)} ({status})" + ) + else: + formatter.emit_text( + f" {colors.muted(source_prefix)}{colors.info(path)} " + f"({colors.muted('not found')})" + ) + + def _output_flat( workspaces: list[dict[str, t.Any]], formatter: OutputFormatter, @@ -399,30 +441,7 @@ def output_workspace(ws: dict[str, t.Any], show_path: bool) -> None: # Output global workspace directories section if global_dir_candidates: - formatter.emit_text("") - formatter.emit_text(colors.heading("Global workspace directories:")) - for candidate in global_dir_candidates: - path = candidate["path"] - source = candidate.get("source", "") - source_prefix = f"{source}: " if source else "" - if candidate["exists"]: - count = candidate["workspace_count"] - status = f"{count} workspace{'s' if count != 1 else ''}" - if candidate["active"]: - status += ", active" - formatter.emit_text( - f" {colors.muted(source_prefix)}{colors.info(path)} " - f"({colors.success(status)})" - ) - else: - formatter.emit_text( - f" {colors.muted(source_prefix)}{colors.info(path)} ({status})" - ) - else: - formatter.emit_text( - f" {colors.muted(source_prefix)}{colors.info(path)} " - f"({colors.muted('not found')})" - ) + _render_global_workspace_dirs(formatter, colors, global_dir_candidates) def _output_tree( @@ -481,30 +500,7 @@ def _output_tree( # Output global workspace directories section if global_dir_candidates: - formatter.emit_text("") - formatter.emit_text(colors.heading("Global workspace directories:")) - for candidate in global_dir_candidates: - path = candidate["path"] - source = candidate.get("source", "") - source_prefix = f"{source}: " if source else "" - if candidate["exists"]: - count = candidate["workspace_count"] - status = f"{count} workspace{'s' if count != 1 else ''}" - if candidate["active"]: - status += ", active" - formatter.emit_text( - f" {colors.muted(source_prefix)}{colors.info(path)} " - f"({colors.success(status)})" - ) - else: - formatter.emit_text( - f" {colors.muted(source_prefix)}{colors.info(path)} ({status})" - ) - else: - formatter.emit_text( - f" {colors.muted(source_prefix)}{colors.info(path)} " - f"({colors.muted('not found')})" - ) + _render_global_workspace_dirs(formatter, colors, global_dir_candidates) def command_ls( @@ -565,28 +561,7 @@ def command_ls( formatter.emit_text(colors.warning("No workspaces found.")) # Still show global workspace directories even with no workspaces if output_mode == OutputMode.HUMAN: - formatter.emit_text("") - formatter.emit_text(colors.heading("Global workspace directories:")) - for candidate in global_dir_candidates: - path = candidate["path"] - source = candidate.get("source", "") - source_prefix = f"{source}: " if source else "" - if candidate["exists"]: - if candidate["active"]: - formatter.emit_text( - f" {colors.muted(source_prefix)}{colors.info(path)} " - f"({colors.success('0 workspaces, active')})" - ) - else: - formatter.emit_text( - f" {colors.muted(source_prefix)}{colors.info(path)} " - f"(0 workspaces)" - ) - else: - formatter.emit_text( - f" {colors.muted(source_prefix)}{colors.info(path)} " - f"({colors.muted('not found')})" - ) + _render_global_workspace_dirs(formatter, colors, global_dir_candidates) elif output_mode == OutputMode.JSON: # Output structured JSON with empty workspaces output_data = { From f6f01d0e60f912bd39449157f35b9380cb7e9663 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 10 Jan 2026 10:03:13 -0600 Subject: [PATCH 69/99] fix(cli[_formatter]): Remove dead -d/--dir from OPTIONS_EXPECTING_VALUE The -d flag is --detached (a boolean flag), not a value option. The --dir option doesn't exist in tmuxp CLI. These entries were benign due to short-circuit evaluation in the parsing logic (OPTIONS_FLAG_ONLY is checked first), but having dead data creates confusion for maintainers. --- src/tmuxp/cli/_formatter.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/tmuxp/cli/_formatter.py b/src/tmuxp/cli/_formatter.py index 4eebaeb919..385668de79 100644 --- a/src/tmuxp/cli/_formatter.py +++ b/src/tmuxp/cli/_formatter.py @@ -33,8 +33,7 @@ "--target", "-o", "--output", - "-d", - "--dir", + # Note: -d is --detached (flag-only), not a value option "--color", "-w", "--workspace", From 7a6bc2eafef523a1b678292cf9ca35a136c1878e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 10 Jan 2026 10:03:21 -0600 Subject: [PATCH 70/99] docs(cli[ls]): Add doctests to helper functions Add working doctests to satisfy AGENTS.md requirement for: - _render_global_workspace_dirs() - _output_flat() - _output_tree() Uses ColorMode.NEVER for deterministic output without ANSI codes. --- src/tmuxp/cli/ls.py | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/tmuxp/cli/ls.py b/src/tmuxp/cli/ls.py index e07bca4341..517f682500 100644 --- a/src/tmuxp/cli/ls.py +++ b/src/tmuxp/cli/ls.py @@ -345,6 +345,24 @@ def _render_global_workspace_dirs( Color manager. global_dir_candidates : list[dict[str, Any]] List of global workspace directory candidates with metadata. + + Examples + -------- + >>> from tmuxp.cli._output import OutputFormatter, OutputMode + >>> from tmuxp.cli._colors import Colors, ColorMode + >>> formatter = OutputFormatter(OutputMode.HUMAN) + >>> colors = Colors(ColorMode.NEVER) + >>> candidates = [ + ... {"path": "~/.tmuxp", "source": "Legacy", "exists": True, + ... "workspace_count": 5, "active": True}, + ... {"path": "~/.config/tmuxp", "source": "XDG", "exists": False, + ... "workspace_count": 0, "active": False}, + ... ] + >>> _render_global_workspace_dirs(formatter, colors, candidates) + + Global workspace directories: + Legacy: ~/.tmuxp (5 workspaces, active) + XDG: ~/.config/tmuxp (not found) """ formatter.emit_text("") formatter.emit_text(colors.heading("Global workspace directories:")) @@ -396,6 +414,17 @@ def _output_flat( If True, show full config details in tree format. Default False. global_dir_candidates : list[dict[str, Any]] | None List of global workspace directory candidates with metadata. + + Examples + -------- + >>> from tmuxp.cli._output import OutputFormatter, OutputMode + >>> from tmuxp.cli._colors import Colors, ColorMode + >>> formatter = OutputFormatter(OutputMode.HUMAN) + >>> colors = Colors(ColorMode.NEVER) + >>> workspaces = [{"name": "dev", "path": "~/.tmuxp/dev.yaml", "source": "global"}] + >>> _output_flat(workspaces, formatter, colors) + Global workspaces: + dev """ # Separate by source for human output grouping local_workspaces = [ws for ws in workspaces if ws["source"] == "local"] @@ -466,6 +495,18 @@ def _output_tree( If True, show full config details in tree format. Default False. global_dir_candidates : list[dict[str, Any]] | None List of global workspace directory candidates with metadata. + + Examples + -------- + >>> from tmuxp.cli._output import OutputFormatter, OutputMode + >>> from tmuxp.cli._colors import Colors, ColorMode + >>> formatter = OutputFormatter(OutputMode.HUMAN) + >>> colors = Colors(ColorMode.NEVER) + >>> workspaces = [{"name": "dev", "path": "~/.tmuxp/dev.yaml", "source": "global"}] + >>> _output_tree(workspaces, formatter, colors) + + ~/.tmuxp + dev """ # Group by parent directory by_directory: dict[str, list[dict[str, t.Any]]] = {} From 5f1e269c673a4b3d16013433a76733d04bb7d86c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 10 Jan 2026 10:52:43 -0600 Subject: [PATCH 71/99] refactor(tests[cli]): Consolidate color test fixtures Add shared fixtures in tests/cli/conftest.py: - colors_always: Colors with ALWAYS mode, NO_COLOR cleared - colors_never: Colors with NEVER mode for plain text tests - mock_home: Mock Path.home() for privacy masking tests Refactor 7 test files to use these fixtures instead of duplicating setup code in each test function. Reduces ~180 lines of repeated test setup boilerplate. --- tests/cli/conftest.py | 33 ++++++++ tests/cli/test_convert_colors.py | 100 +++++++++-------------- tests/cli/test_debug_info_colors.py | 98 ++++++++--------------- tests/cli/test_edit_colors.py | 82 +++++++------------ tests/cli/test_freeze_colors.py | 120 +++++++++++----------------- tests/cli/test_import_colors.py | 105 +++++++++--------------- tests/cli/test_prompt_colors.py | 53 ++++-------- tests/cli/test_shell_colors.py | 72 ++++++----------- 8 files changed, 258 insertions(+), 405 deletions(-) create mode 100644 tests/cli/conftest.py diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py new file mode 100644 index 0000000000..58f0b1d0cc --- /dev/null +++ b/tests/cli/conftest.py @@ -0,0 +1,33 @@ +"""Shared pytest fixtures for CLI tests.""" + +from __future__ import annotations + +import pathlib + +import pytest + +from tmuxp.cli._colors import ColorMode, Colors + + +@pytest.fixture +def colors_always(monkeypatch: pytest.MonkeyPatch) -> Colors: + """Colors instance with ALWAYS mode and NO_COLOR cleared.""" + monkeypatch.delenv("NO_COLOR", raising=False) + return Colors(ColorMode.ALWAYS) + + +@pytest.fixture +def colors_never() -> Colors: + """Colors instance with colors disabled.""" + return Colors(ColorMode.NEVER) + + +@pytest.fixture +def mock_home(monkeypatch: pytest.MonkeyPatch) -> pathlib.Path: + """Mock home directory for privacy tests. + + Sets pathlib.Path.home() to return /home/testuser. + """ + home = pathlib.Path("/home/testuser") + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + return home diff --git a/tests/cli/test_convert_colors.py b/tests/cli/test_convert_colors.py index 9af42bd7ce..8c24ca537e 100644 --- a/tests/cli/test_convert_colors.py +++ b/tests/cli/test_convert_colors.py @@ -4,65 +4,49 @@ import pathlib -import pytest - from tmuxp._internal.private_path import PrivatePath -from tmuxp.cli._colors import ColorMode, Colors +from tmuxp.cli._colors import Colors # Convert command color output tests -def test_convert_success_message(monkeypatch: pytest.MonkeyPatch) -> None: +def test_convert_success_message(colors_always: Colors) -> None: """Verify success messages use success color (green).""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - - result = colors.success("New workspace file saved to ") + result = colors_always.success("New workspace file saved to ") assert "\033[32m" in result # green foreground assert "New workspace file saved to" in result -def test_convert_file_path_uses_info(monkeypatch: pytest.MonkeyPatch) -> None: +def test_convert_file_path_uses_info(colors_always: Colors) -> None: """Verify file paths use info color (cyan).""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - path = "/path/to/config.yaml" - result = colors.info(path) + result = colors_always.info(path) assert "\033[36m" in result # cyan foreground assert path in result -def test_convert_format_type_highlighted(monkeypatch: pytest.MonkeyPatch) -> None: +def test_convert_format_type_highlighted(colors_always: Colors) -> None: """Verify format type uses highlight color (magenta + bold).""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - for fmt in ["json", "yaml"]: - result = colors.highlight(fmt) + result = colors_always.highlight(fmt) assert "\033[35m" in result # magenta foreground assert "\033[1m" in result # bold assert fmt in result -def test_convert_colors_disabled_plain_text() -> None: +def test_convert_colors_disabled_plain_text(colors_never: Colors) -> None: """Verify disabled colors return plain text.""" - colors = Colors(ColorMode.NEVER) - - assert colors.success("success") == "success" - assert colors.info("info") == "info" - assert colors.highlight("highlight") == "highlight" + assert colors_never.success("success") == "success" + assert colors_never.info("info") == "info" + assert colors_never.highlight("highlight") == "highlight" -def test_convert_combined_success_format(monkeypatch: pytest.MonkeyPatch) -> None: +def test_convert_combined_success_format(colors_always: Colors) -> None: """Verify combined success + info format for save message.""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - newfile = "/home/user/.tmuxp/session.json" output = ( - colors.success("New workspace file saved to ") - + colors.info(f"<{newfile}>") + colors_always.success("New workspace file saved to ") + + colors_always.info(f"<{newfile}>") + "." ) # Should contain both green and cyan ANSI codes @@ -73,15 +57,13 @@ def test_convert_combined_success_format(monkeypatch: pytest.MonkeyPatch) -> Non assert output.endswith(".") -def test_convert_prompt_format_with_highlight(monkeypatch: pytest.MonkeyPatch) -> None: +def test_convert_prompt_format_with_highlight(colors_always: Colors) -> None: """Verify prompt uses info for path and highlight for format.""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - workspace_file = "/path/to/config.yaml" to_filetype = "json" prompt = ( - f"Convert {colors.info(workspace_file)} to {colors.highlight(to_filetype)}?" + f"Convert {colors_always.info(workspace_file)} " + f"to {colors_always.highlight(to_filetype)}?" ) assert "\033[36m" in prompt # cyan for file path assert "\033[35m" in prompt # magenta for format type @@ -89,13 +71,10 @@ def test_convert_prompt_format_with_highlight(monkeypatch: pytest.MonkeyPatch) - assert to_filetype in prompt -def test_convert_save_prompt_format(monkeypatch: pytest.MonkeyPatch) -> None: +def test_convert_save_prompt_format(colors_always: Colors) -> None: """Verify save prompt uses info color for new file path.""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - newfile = "/path/to/config.json" - prompt = f"Save workspace to {colors.info(newfile)}?" + prompt = f"Save workspace to {colors_always.info(newfile)}?" assert "\033[36m" in prompt # cyan for file path assert newfile in prompt assert "Save workspace to" in prompt @@ -104,42 +83,39 @@ def test_convert_save_prompt_format(monkeypatch: pytest.MonkeyPatch) -> None: # Privacy masking tests -def test_convert_masks_home_in_convert_prompt(monkeypatch: pytest.MonkeyPatch) -> None: +def test_convert_masks_home_in_convert_prompt( + colors_always: Colors, + mock_home: pathlib.Path, +) -> None: """Convert should mask home directory in convert prompt.""" - 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/.tmuxp/session.yaml") - prompt = f"Convert {colors.info(str(PrivatePath(workspace_file)))} to json?" + workspace_file = mock_home / ".tmuxp/session.yaml" + prompt = f"Convert {colors_always.info(str(PrivatePath(workspace_file)))} to json?" assert "~/.tmuxp/session.yaml" in prompt assert "/home/testuser" not in prompt -def test_convert_masks_home_in_save_prompt(monkeypatch: pytest.MonkeyPatch) -> None: +def test_convert_masks_home_in_save_prompt( + colors_always: Colors, + mock_home: pathlib.Path, +) -> None: """Convert should mask home directory in save prompt.""" - monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - - newfile = pathlib.Path("/home/testuser/.tmuxp/session.json") - prompt = f"Save workspace to {colors.info(str(PrivatePath(newfile)))}?" + newfile = mock_home / ".tmuxp/session.json" + prompt = f"Save workspace to {colors_always.info(str(PrivatePath(newfile)))}?" assert "~/.tmuxp/session.json" in prompt assert "/home/testuser" not in prompt -def test_convert_masks_home_in_saved_message(monkeypatch: pytest.MonkeyPatch) -> None: +def test_convert_masks_home_in_saved_message( + colors_always: Colors, + mock_home: pathlib.Path, +) -> None: """Convert should mask home directory in saved message.""" - monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - - newfile = pathlib.Path("/home/testuser/.tmuxp/session.json") + newfile = mock_home / ".tmuxp/session.json" output = ( - colors.success("New workspace file saved to ") - + colors.info(str(PrivatePath(newfile))) + colors_always.success("New workspace file saved to ") + + colors_always.info(str(PrivatePath(newfile))) + "." ) diff --git a/tests/cli/test_debug_info_colors.py b/tests/cli/test_debug_info_colors.py index 2e890a7ea2..f1a553f3b9 100644 --- a/tests/cli/test_debug_info_colors.py +++ b/tests/cli/test_debug_info_colors.py @@ -12,24 +12,18 @@ # Privacy masking in debug-info context -def test_debug_info_masks_home_in_paths(monkeypatch: pytest.MonkeyPatch) -> None: +def test_debug_info_masks_home_in_paths(mock_home: pathlib.Path) -> None: """debug-info should mask home directory in paths.""" - monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) - # Simulate what debug-info does with tmuxp_path - tmuxp_path = pathlib.Path("/home/testuser/work/python/tmuxp/src/tmuxp") + tmuxp_path = mock_home / "work/python/tmuxp/src/tmuxp" private_path = str(PrivatePath(tmuxp_path)) assert private_path == "~/work/python/tmuxp/src/tmuxp" assert "/home/testuser" not in private_path -def test_debug_info_masks_home_in_system_path( - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_debug_info_masks_home_in_system_path(mock_home: pathlib.Path) -> None: """debug-info should mask home directory in system PATH.""" - monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) - path_env = "/home/testuser/.local/bin:/usr/bin:/home/testuser/.cargo/bin" masked = collapse_home_in_string(path_env) @@ -37,10 +31,8 @@ def test_debug_info_masks_home_in_system_path( assert "/home/testuser" not in masked -def test_debug_info_preserves_system_paths(monkeypatch: pytest.MonkeyPatch) -> None: +def test_debug_info_preserves_system_paths(mock_home: pathlib.Path) -> None: """debug-info should preserve paths outside home directory.""" - monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) - tmux_path = "/usr/bin/tmux" private_path = str(PrivatePath(tmux_path)) @@ -50,44 +42,36 @@ def test_debug_info_preserves_system_paths(monkeypatch: pytest.MonkeyPatch) -> N # Formatting helpers in debug-info context -def test_debug_info_format_kv_labels(monkeypatch: pytest.MonkeyPatch) -> None: +def test_debug_info_format_kv_labels(colors_always: Colors) -> None: """debug-info should highlight labels in key-value pairs.""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - - result = colors.format_kv("tmux version", "3.2a") + result = colors_always.format_kv("tmux version", "3.2a") assert "\033[35m" in result # magenta for label assert "\033[1m" in result # bold for label assert "tmux version" in result assert "3.2a" in result -def test_debug_info_format_version(monkeypatch: pytest.MonkeyPatch) -> None: +def test_debug_info_format_version(colors_always: Colors) -> None: """debug-info should highlight version strings.""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - - result = colors.format_kv("tmux version", colors.format_version("3.2a")) + result = colors_always.format_kv( + "tmux version", colors_always.format_version("3.2a") + ) assert "\033[36m" in result # cyan for version assert "3.2a" in result -def test_debug_info_format_path(monkeypatch: pytest.MonkeyPatch) -> None: +def test_debug_info_format_path(colors_always: Colors) -> None: """debug-info should highlight paths.""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - - result = colors.format_kv("tmux path", colors.format_path("/usr/bin/tmux")) + result = colors_always.format_kv( + "tmux path", colors_always.format_path("/usr/bin/tmux") + ) assert "\033[36m" in result # cyan for path assert "/usr/bin/tmux" in result -def test_debug_info_format_separator(monkeypatch: pytest.MonkeyPatch) -> None: +def test_debug_info_format_separator(colors_always: Colors) -> None: """debug-info should use muted separators.""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - - result = colors.format_separator() + result = colors_always.format_separator() assert "\033[34m" in result # blue for muted assert "-" * 25 in result @@ -95,28 +79,18 @@ def test_debug_info_format_separator(monkeypatch: pytest.MonkeyPatch) -> None: # tmux option formatting -def test_debug_info_format_tmux_option_space_sep( - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_debug_info_format_tmux_option_space_sep(colors_always: Colors) -> None: """debug-info should format space-separated tmux options.""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - - result = colors.format_tmux_option("status on") + result = colors_always.format_tmux_option("status on") assert "\033[35m" in result # magenta for key assert "\033[36m" in result # cyan for value assert "status" in result assert "on" in result -def test_debug_info_format_tmux_option_equals_sep( - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_debug_info_format_tmux_option_equals_sep(colors_always: Colors) -> None: """debug-info should format equals-separated tmux options.""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - - result = colors.format_tmux_option("base-index=0") + result = colors_always.format_tmux_option("base-index=0") assert "\033[35m" in result # magenta for key assert "\033[36m" in result # cyan for value assert "base-index" in result @@ -126,11 +100,9 @@ def test_debug_info_format_tmux_option_equals_sep( # Color mode behavior -def test_debug_info_respects_never_mode() -> None: +def test_debug_info_respects_never_mode(colors_never: Colors) -> None: """debug-info should return plain text in NEVER mode.""" - colors = Colors(ColorMode.NEVER) - - result = colors.format_kv("tmux version", colors.format_version("3.2a")) + result = colors_never.format_kv("tmux version", colors_never.format_version("3.2a")) assert "\033[" not in result assert result == "tmux version: 3.2a" @@ -149,17 +121,16 @@ def test_debug_info_respects_no_color_env(monkeypatch: pytest.MonkeyPatch) -> No def test_debug_info_combined_path_with_privacy( - monkeypatch: pytest.MonkeyPatch, + colors_always: Colors, + mock_home: pathlib.Path, ) -> None: """debug-info should combine privacy masking with color formatting.""" - monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - # Simulate what debug-info does - raw_path = "/home/testuser/work/tmuxp/src/tmuxp" + raw_path = mock_home / "work/tmuxp/src/tmuxp" private_path = str(PrivatePath(raw_path)) - formatted = colors.format_kv("tmuxp path", colors.format_path(private_path)) + formatted = colors_always.format_kv( + "tmuxp path", colors_always.format_path(private_path) + ) assert "~/work/tmuxp/src/tmuxp" in formatted assert "/home/testuser" not in formatted @@ -167,19 +138,14 @@ def test_debug_info_combined_path_with_privacy( assert "\033[35m" in formatted # magenta for label -def test_debug_info_environment_section_format( - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_debug_info_environment_section_format(colors_always: Colors) -> None: """debug-info environment section should have proper format.""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - # Simulate environment section format env_items = [ - f"\t{colors.format_kv('dist', 'Linux-6.6.87')}", - f"\t{colors.format_kv('arch', 'x86_64')}", + f"\t{colors_always.format_kv('dist', 'Linux-6.6.87')}", + f"\t{colors_always.format_kv('arch', 'x86_64')}", ] - section = f"{colors.format_label('environment')}:\n" + "\n".join(env_items) + section = f"{colors_always.format_label('environment')}:\n" + "\n".join(env_items) assert "environment" in section assert "\t" in section # indented items diff --git a/tests/cli/test_edit_colors.py b/tests/cli/test_edit_colors.py index 8479c3fa64..8b23caf97b 100644 --- a/tests/cli/test_edit_colors.py +++ b/tests/cli/test_edit_colors.py @@ -4,27 +4,22 @@ import pathlib -import pytest - from tmuxp._internal.private_path import PrivatePath -from tmuxp.cli._colors import ColorMode, Colors +from tmuxp.cli._colors import Colors # Edit command color output tests -def test_edit_opening_message_format(monkeypatch: pytest.MonkeyPatch) -> None: +def test_edit_opening_message_format(colors_always: Colors) -> None: """Verify opening message format with file path and editor.""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - workspace_file = "/home/user/.tmuxp/dev.yaml" editor = "vim" output = ( - colors.muted("Opening ") - + colors.info(workspace_file) - + colors.muted(" in ") - + colors.highlight(editor, bold=False) - + colors.muted("...") + colors_always.muted("Opening ") + + colors_always.info(workspace_file) + + colors_always.muted(" in ") + + colors_always.highlight(editor, bold=False) + + colors_always.muted("...") ) # Should contain blue, cyan, and magenta ANSI codes assert "\033[34m" in output # blue for muted @@ -34,65 +29,51 @@ def test_edit_opening_message_format(monkeypatch: pytest.MonkeyPatch) -> None: assert editor in output -def test_edit_file_path_uses_info(monkeypatch: pytest.MonkeyPatch) -> None: +def test_edit_file_path_uses_info(colors_always: Colors) -> None: """Verify file paths use info color (cyan).""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - path = "/path/to/workspace.yaml" - result = colors.info(path) + result = colors_always.info(path) assert "\033[36m" in result # cyan foreground assert path in result -def test_edit_editor_highlighted(monkeypatch: pytest.MonkeyPatch) -> None: +def test_edit_editor_highlighted(colors_always: Colors) -> None: """Verify editor name uses highlight color without bold.""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - for editor in ["vim", "nano", "code", "emacs", "nvim"]: - result = colors.highlight(editor, bold=False) + result = colors_always.highlight(editor, bold=False) assert "\033[35m" in result # magenta foreground assert "\033[1m" not in result # no bold - subtle assert editor in result -def test_edit_muted_for_static_text(monkeypatch: pytest.MonkeyPatch) -> None: +def test_edit_muted_for_static_text(colors_always: Colors) -> None: """Verify static text uses muted color (blue).""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - - result = colors.muted("Opening ") + result = colors_always.muted("Opening ") assert "\033[34m" in result # blue foreground assert "Opening" in result -def test_edit_colors_disabled_plain_text() -> None: +def test_edit_colors_disabled_plain_text(colors_never: Colors) -> None: """Verify disabled colors return plain text.""" - colors = Colors(ColorMode.NEVER) - workspace_file = "/home/user/.tmuxp/dev.yaml" editor = "vim" output = ( - colors.muted("Opening ") - + colors.info(workspace_file) - + colors.muted(" in ") - + colors.highlight(editor, bold=False) - + colors.muted("...") + colors_never.muted("Opening ") + + colors_never.info(workspace_file) + + colors_never.muted(" in ") + + colors_never.highlight(editor, bold=False) + + colors_never.muted("...") ) # Should be plain text without ANSI codes assert "\033[" not in output assert output == f"Opening {workspace_file} in {editor}..." -def test_edit_various_editors(monkeypatch: pytest.MonkeyPatch) -> None: +def test_edit_various_editors(colors_always: Colors) -> None: """Verify common editors can be highlighted.""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - editors = ["vim", "nvim", "nano", "code", "emacs", "hx", "micro"] for editor in editors: - result = colors.highlight(editor, bold=False) + result = colors_always.highlight(editor, bold=False) assert "\033[35m" in result assert editor in result @@ -100,20 +81,19 @@ def test_edit_various_editors(monkeypatch: pytest.MonkeyPatch) -> None: # Privacy masking tests -def test_edit_masks_home_in_opening_message(monkeypatch: pytest.MonkeyPatch) -> None: +def test_edit_masks_home_in_opening_message( + colors_always: Colors, + mock_home: pathlib.Path, +) -> None: """Edit should mask home directory in 'Opening' message.""" - 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/.tmuxp/dev.yaml") + workspace_file = mock_home / ".tmuxp/dev.yaml" editor = "vim" output = ( - colors.muted("Opening ") - + colors.info(str(PrivatePath(workspace_file))) - + colors.muted(" in ") - + colors.highlight(editor, bold=False) - + colors.muted("...") + colors_always.muted("Opening ") + + colors_always.info(str(PrivatePath(workspace_file))) + + colors_always.muted(" in ") + + colors_always.highlight(editor, bold=False) + + colors_always.muted("...") ) assert "~/.tmuxp/dev.yaml" in output diff --git a/tests/cli/test_freeze_colors.py b/tests/cli/test_freeze_colors.py index 0774c1ebc2..7c36459175 100644 --- a/tests/cli/test_freeze_colors.py +++ b/tests/cli/test_freeze_colors.py @@ -4,87 +4,65 @@ import pathlib -import pytest - from tmuxp._internal.private_path import PrivatePath -from tmuxp.cli._colors import ColorMode, Colors +from tmuxp.cli._colors import Colors # Freeze command color output tests -def test_freeze_error_uses_red(monkeypatch: pytest.MonkeyPatch) -> None: +def test_freeze_error_uses_red(colors_always: Colors) -> None: """Verify error messages use error color (red).""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - msg = "Session not found" - result = colors.error(msg) + result = colors_always.error(msg) assert "\033[31m" in result # red foreground assert msg in result assert result.endswith("\033[0m") # reset at end -def test_freeze_success_message(monkeypatch: pytest.MonkeyPatch) -> None: +def test_freeze_success_message(colors_always: Colors) -> None: """Verify success messages use success color (green).""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - - result = colors.success("Saved to ") + result = colors_always.success("Saved to ") assert "\033[32m" in result # green foreground assert "Saved to" in result -def test_freeze_file_path_uses_info(monkeypatch: pytest.MonkeyPatch) -> None: +def test_freeze_file_path_uses_info(colors_always: Colors) -> None: """Verify file paths use info color (cyan).""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - path = "/path/to/config.yaml" - result = colors.info(path) + result = colors_always.info(path) assert "\033[36m" in result # cyan foreground assert path in result -def test_freeze_warning_file_exists(monkeypatch: pytest.MonkeyPatch) -> None: +def test_freeze_warning_file_exists(colors_always: Colors) -> None: """Verify file exists warning uses warning color (yellow).""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - msg = "/path/to/config.yaml exists." - result = colors.warning(msg) + result = colors_always.warning(msg) assert "\033[33m" in result # yellow foreground assert msg in result -def test_freeze_muted_for_secondary_text(monkeypatch: pytest.MonkeyPatch) -> None: +def test_freeze_muted_for_secondary_text(colors_always: Colors) -> None: """Verify secondary text uses muted color (blue).""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - msg = "Freeze does its best to snapshot live tmux sessions." - result = colors.muted(msg) + result = colors_always.muted(msg) assert "\033[34m" in result # blue foreground assert msg in result -def test_freeze_colors_disabled_plain_text() -> None: +def test_freeze_colors_disabled_plain_text(colors_never: Colors) -> None: """Verify disabled colors return plain text.""" - colors = Colors(ColorMode.NEVER) - - assert colors.error("error") == "error" - assert colors.success("success") == "success" - assert colors.warning("warning") == "warning" - assert colors.info("info") == "info" - assert colors.muted("muted") == "muted" + assert colors_never.error("error") == "error" + assert colors_never.success("success") == "success" + assert colors_never.warning("warning") == "warning" + assert colors_never.info("info") == "info" + assert colors_never.muted("muted") == "muted" -def test_freeze_combined_output_format(monkeypatch: pytest.MonkeyPatch) -> None: +def test_freeze_combined_output_format(colors_always: Colors) -> None: """Verify combined success + info format for 'Saved to ' message.""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - dest = "/home/user/.tmuxp/session.yaml" - output = colors.success("Saved to ") + colors.info(dest) + "." + output = colors_always.success("Saved to ") + colors_always.info(dest) + "." # Should contain both green and cyan ANSI codes assert "\033[32m" in output # green for "Saved to" assert "\033[36m" in output # cyan for path @@ -93,14 +71,13 @@ def test_freeze_combined_output_format(monkeypatch: pytest.MonkeyPatch) -> None: assert output.endswith(".") -def test_freeze_warning_with_instructions(monkeypatch: pytest.MonkeyPatch) -> None: +def test_freeze_warning_with_instructions(colors_always: Colors) -> None: """Verify warning + muted format for file exists message.""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - path = "/path/to/config.yaml" output = ( - colors.warning(f"{path} exists.") + " " + colors.muted("Pick a new filename.") + colors_always.warning(f"{path} exists.") + + " " + + colors_always.muted("Pick a new filename.") ) # Should contain both yellow and blue ANSI codes assert "\033[33m" in output # yellow for warning @@ -109,13 +86,10 @@ def test_freeze_warning_with_instructions(monkeypatch: pytest.MonkeyPatch) -> No assert "Pick a new filename." in output -def test_freeze_url_highlighted_in_help(monkeypatch: pytest.MonkeyPatch) -> None: +def test_freeze_url_highlighted_in_help(colors_always: Colors) -> None: """Verify URLs use info color in help text.""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - url = "" - help_text = colors.muted("tmuxp has examples at ") + colors.info(url) + help_text = colors_always.muted("tmuxp has examples at ") + colors_always.info(url) assert "\033[34m" in help_text # blue for muted text assert "\033[36m" in help_text # cyan for URL assert url in help_text @@ -124,50 +98,46 @@ def test_freeze_url_highlighted_in_help(monkeypatch: pytest.MonkeyPatch) -> None # Privacy masking tests -def test_freeze_masks_home_in_saved_message(monkeypatch: pytest.MonkeyPatch) -> None: +def test_freeze_masks_home_in_saved_message( + colors_always: Colors, + mock_home: pathlib.Path, +) -> None: """Freeze should mask home directory in 'Saved to' message.""" - monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - - dest = "/home/testuser/.tmuxp/session.yaml" - output = colors.success("Saved to ") + colors.info(str(PrivatePath(dest))) + "." + dest = mock_home / ".tmuxp/session.yaml" + output = ( + colors_always.success("Saved to ") + + colors_always.info(str(PrivatePath(dest))) + + "." + ) assert "~/.tmuxp/session.yaml" in output assert "/home/testuser" not in output -def test_freeze_masks_home_in_exists_warning(monkeypatch: pytest.MonkeyPatch) -> None: +def test_freeze_masks_home_in_exists_warning( + colors_always: Colors, + mock_home: pathlib.Path, +) -> None: """Freeze should mask home directory in 'exists' warning.""" - monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - - dest_prompt = "/home/testuser/.tmuxp/session.yaml" - output = colors.warning(f"{PrivatePath(dest_prompt)} exists.") + dest_prompt = mock_home / ".tmuxp/session.yaml" + output = colors_always.warning(f"{PrivatePath(dest_prompt)} exists.") assert "~/.tmuxp/session.yaml exists." in output assert "/home/testuser" not in output -def test_freeze_masks_home_in_save_to_prompt(monkeypatch: pytest.MonkeyPatch) -> None: +def test_freeze_masks_home_in_save_to_prompt(mock_home: pathlib.Path) -> None: """Freeze should mask home directory in 'Save to:' prompt.""" - monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) - - save_to = "/home/testuser/.tmuxp/session.yaml" + save_to = mock_home / ".tmuxp/session.yaml" prompt_text = f"Save to: {PrivatePath(save_to)}" assert "~/.tmuxp/session.yaml" in prompt_text assert "/home/testuser" not in prompt_text -def test_freeze_masks_home_in_save_confirmation( - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_freeze_masks_home_in_save_confirmation(mock_home: pathlib.Path) -> None: """Freeze should mask home directory in 'Save to ...?' confirmation.""" - monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) - - dest = "/home/testuser/.tmuxp/session.yaml" + dest = mock_home / ".tmuxp/session.yaml" prompt_text = f"Save to {PrivatePath(dest)}?" assert "~/.tmuxp/session.yaml" in prompt_text diff --git a/tests/cli/test_import_colors.py b/tests/cli/test_import_colors.py index 59ad3b6075..eb8b829ca9 100644 --- a/tests/cli/test_import_colors.py +++ b/tests/cli/test_import_colors.py @@ -4,86 +4,64 @@ import pathlib -import pytest - from tmuxp._internal.private_path import PrivatePath -from tmuxp.cli._colors import ColorMode, Colors +from tmuxp.cli._colors import Colors # Import command color output tests -def test_import_error_unknown_format(monkeypatch: pytest.MonkeyPatch) -> None: +def test_import_error_unknown_format(colors_always: Colors) -> None: """Verify unknown format error uses error color (red).""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - msg = "Unknown config format." - result = colors.error(msg) + result = colors_always.error(msg) assert "\033[31m" in result # red foreground assert msg in result assert result.endswith("\033[0m") # reset at end -def test_import_success_message(monkeypatch: pytest.MonkeyPatch) -> None: +def test_import_success_message(colors_always: Colors) -> None: """Verify success messages use success color (green).""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - - result = colors.success("Saved to ") + result = colors_always.success("Saved to ") assert "\033[32m" in result # green foreground assert "Saved to" in result -def test_import_file_path_uses_info(monkeypatch: pytest.MonkeyPatch) -> None: +def test_import_file_path_uses_info(colors_always: Colors) -> None: """Verify file paths use info color (cyan).""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - path = "/path/to/config.yaml" - result = colors.info(path) + result = colors_always.info(path) assert "\033[36m" in result # cyan foreground assert path in result -def test_import_muted_for_banner(monkeypatch: pytest.MonkeyPatch) -> None: +def test_import_muted_for_banner(colors_always: Colors) -> None: """Verify banner text uses muted color (blue).""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - msg = "Configuration import does its best to convert files." - result = colors.muted(msg) + result = colors_always.muted(msg) assert "\033[34m" in result # blue foreground assert msg in result -def test_import_muted_for_separator(monkeypatch: pytest.MonkeyPatch) -> None: +def test_import_muted_for_separator(colors_always: Colors) -> None: """Verify separator uses muted color (blue).""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - separator = "---------------------------------------------------------------" - result = colors.muted(separator) + result = colors_always.muted(separator) assert "\033[34m" in result # blue foreground assert separator in result -def test_import_colors_disabled_plain_text() -> None: +def test_import_colors_disabled_plain_text(colors_never: Colors) -> None: """Verify disabled colors return plain text.""" - colors = Colors(ColorMode.NEVER) + assert colors_never.error("error") == "error" + assert colors_never.success("success") == "success" + assert colors_never.muted("muted") == "muted" + assert colors_never.info("info") == "info" - assert colors.error("error") == "error" - assert colors.success("success") == "success" - assert colors.muted("muted") == "muted" - assert colors.info("info") == "info" - -def test_import_combined_success_format(monkeypatch: pytest.MonkeyPatch) -> None: +def test_import_combined_success_format(colors_always: Colors) -> None: """Verify combined success + info format for 'Saved to ' message.""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - dest = "/home/user/.tmuxp/session.yaml" - output = colors.success("Saved to ") + colors.info(dest) + "." + output = colors_always.success("Saved to ") + colors_always.info(dest) + "." # Should contain both green and cyan ANSI codes assert "\033[32m" in output # green for "Saved to" assert "\033[36m" in output # cyan for path @@ -92,32 +70,26 @@ def test_import_combined_success_format(monkeypatch: pytest.MonkeyPatch) -> None assert output.endswith(".") -def test_import_help_text_with_urls(monkeypatch: pytest.MonkeyPatch) -> None: +def test_import_help_text_with_urls(colors_always: Colors) -> None: """Verify help text uses muted for text and info for URLs.""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - url = "" - help_text = colors.muted( + help_text = colors_always.muted( "tmuxp has examples in JSON and YAML format at " - ) + colors.info(url) + ) + colors_always.info(url) assert "\033[34m" in help_text # blue for muted text assert "\033[36m" in help_text # cyan for URL assert url in help_text -def test_import_banner_with_separator(monkeypatch: pytest.MonkeyPatch) -> None: +def test_import_banner_with_separator(colors_always: Colors) -> None: """Verify banner format with separator and instruction text.""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - config_content = "session_name: test\n" separator = "---------------------------------------------------------------" output = ( config_content - + colors.muted(separator) + + colors_always.muted(separator) + "\n" - + colors.muted("Configuration import does its best to convert files.") + + colors_always.muted("Configuration import does its best to convert files.") + "\n" ) # Should contain blue ANSI code for muted sections @@ -130,36 +102,35 @@ def test_import_banner_with_separator(monkeypatch: pytest.MonkeyPatch) -> None: # Privacy masking tests -def test_import_masks_home_in_save_prompt(monkeypatch: pytest.MonkeyPatch) -> None: +def test_import_masks_home_in_save_prompt(mock_home: pathlib.Path) -> None: """Import should mask home directory in save prompt.""" - monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) - - cwd = "/home/testuser/projects" + cwd = mock_home / "projects" prompt = f"Save to [{PrivatePath(cwd)}]" assert "[~/projects]" in prompt assert "/home/testuser" not in prompt -def test_import_masks_home_in_confirm_prompt(monkeypatch: pytest.MonkeyPatch) -> None: +def test_import_masks_home_in_confirm_prompt(mock_home: pathlib.Path) -> None: """Import should mask home directory in confirmation prompt.""" - monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) - - dest_path = "/home/testuser/.tmuxp/imported.yaml" + dest_path = mock_home / ".tmuxp/imported.yaml" prompt = f"Save to {PrivatePath(dest_path)}?" assert "~/.tmuxp/imported.yaml" in prompt assert "/home/testuser" not in prompt -def test_import_masks_home_in_saved_message(monkeypatch: pytest.MonkeyPatch) -> None: +def test_import_masks_home_in_saved_message( + colors_always: Colors, + mock_home: pathlib.Path, +) -> None: """Import should mask home directory in 'Saved to' message.""" - monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - - dest = "/home/testuser/.tmuxp/imported.yaml" - output = colors.success("Saved to ") + colors.info(str(PrivatePath(dest))) + "." + dest = mock_home / ".tmuxp/imported.yaml" + output = ( + colors_always.success("Saved to ") + + colors_always.info(str(PrivatePath(dest))) + + "." + ) assert "~/.tmuxp/imported.yaml" in output assert "/home/testuser" not in output diff --git a/tests/cli/test_prompt_colors.py b/tests/cli/test_prompt_colors.py index 1013626ea0..738761cae4 100644 --- a/tests/cli/test_prompt_colors.py +++ b/tests/cli/test_prompt_colors.py @@ -9,50 +9,36 @@ from tmuxp.cli._colors import ColorMode, Colors -def test_prompt_bool_choice_indicator_muted(monkeypatch: pytest.MonkeyPatch) -> None: +def test_prompt_bool_choice_indicator_muted(colors_always: Colors) -> None: """Verify [Y/n] uses muted color (blue).""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - # Test the muted color is applied to choice indicators - result = colors.muted("[Y/n]") + result = colors_always.muted("[Y/n]") assert "\033[34m" in result # blue foreground assert "[Y/n]" in result assert result.endswith("\033[0m") -def test_prompt_bool_choice_indicator_variants( - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_prompt_bool_choice_indicator_variants(colors_always: Colors) -> None: """Verify all choice indicator variants are colored.""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - for indicator in ["[Y/n]", "[y/N]", "[y/n]"]: - result = colors.muted(indicator) + result = colors_always.muted(indicator) assert "\033[34m" in result assert indicator in result -def test_prompt_default_value_uses_info(monkeypatch: pytest.MonkeyPatch) -> None: +def test_prompt_default_value_uses_info(colors_always: Colors) -> None: """Verify default path uses info color (cyan).""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - path = "/home/user/.tmuxp/session.yaml" - result = colors.info(f"[{path}]") + result = colors_always.info(f"[{path}]") assert "\033[36m" in result # cyan foreground assert path in result assert result.endswith("\033[0m") -def test_prompt_choices_list_muted(monkeypatch: pytest.MonkeyPatch) -> None: +def test_prompt_choices_list_muted(colors_always: Colors) -> None: """Verify (yaml, json) uses muted color (blue).""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - choices = "(yaml, json)" - result = colors.muted(choices) + result = colors_always.muted(choices) assert "\033[34m" in result # blue foreground assert choices in result @@ -66,14 +52,11 @@ def test_prompts_respect_no_color_env(monkeypatch: pytest.MonkeyPatch) -> None: assert colors.info("[default]") == "[default]" -def test_prompt_combined_format(monkeypatch: pytest.MonkeyPatch) -> None: +def test_prompt_combined_format(colors_always: Colors) -> None: """Verify combined prompt format with choices and default.""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - name = "Convert to" - choices_str = colors.muted("(yaml, json)") - default_str = colors.info("[yaml]") + choices_str = colors_always.muted("(yaml, json)") + default_str = colors_always.info("[yaml]") prompt = f"{name} - {choices_str} {default_str}" # Should contain both blue (muted) and cyan (info) ANSI codes @@ -83,16 +66,12 @@ def test_prompt_combined_format(monkeypatch: pytest.MonkeyPatch) -> None: assert "yaml, json" in prompt -def test_prompt_colors_disabled_returns_plain_text( - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_prompt_colors_disabled_returns_plain_text(colors_never: Colors) -> None: """Verify disabled colors return plain text without ANSI codes.""" - colors = Colors(ColorMode.NEVER) - - assert colors.muted("[Y/n]") == "[Y/n]" - assert colors.info("[/path/to/file]") == "[/path/to/file]" - assert "\033[" not in colors.muted("test") - assert "\033[" not in colors.info("test") + assert colors_never.muted("[Y/n]") == "[Y/n]" + assert colors_never.info("[/path/to/file]") == "[/path/to/file]" + assert "\033[" not in colors_never.muted("test") + assert "\033[" not in colors_never.info("test") def test_prompt_empty_input_no_default_reprompts( diff --git a/tests/cli/test_shell_colors.py b/tests/cli/test_shell_colors.py index 21296b0a86..0bce85083d 100644 --- a/tests/cli/test_shell_colors.py +++ b/tests/cli/test_shell_colors.py @@ -2,26 +2,21 @@ from __future__ import annotations -import pytest - -from tmuxp.cli._colors import ColorMode, Colors +from tmuxp.cli._colors import Colors # Shell command color output tests -def test_shell_launch_message_format(monkeypatch: pytest.MonkeyPatch) -> None: +def test_shell_launch_message_format(colors_always: Colors) -> None: """Verify launch message format with shell type and session.""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - shell_name = "ipython" session_name = "my-session" output = ( - colors.muted("Launching ") - + colors.highlight(shell_name, bold=False) - + colors.muted(" shell for session ") - + colors.info(session_name) - + colors.muted("...") + colors_always.muted("Launching ") + + colors_always.highlight(shell_name, bold=False) + + colors_always.muted(" shell for session ") + + colors_always.info(session_name) + + colors_always.muted("...") ) # Should contain blue, magenta, and cyan ANSI codes assert "\033[34m" in output # blue for muted @@ -31,76 +26,59 @@ def test_shell_launch_message_format(monkeypatch: pytest.MonkeyPatch) -> None: assert session_name in output -def test_shell_pdb_launch_message(monkeypatch: pytest.MonkeyPatch) -> None: +def test_shell_pdb_launch_message(colors_always: Colors) -> None: """Verify pdb launch message format.""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - output = ( - colors.muted("Launching ") - + colors.highlight("pdb", bold=False) - + colors.muted(" shell...") + colors_always.muted("Launching ") + + colors_always.highlight("pdb", bold=False) + + colors_always.muted(" shell...") ) assert "\033[34m" in output # blue for muted assert "\033[35m" in output # magenta for pdb assert "pdb" in output -def test_shell_highlight_not_bold(monkeypatch: pytest.MonkeyPatch) -> None: +def test_shell_highlight_not_bold(colors_always: Colors) -> None: """Verify shell name uses highlight without bold for subtlety.""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - - result = colors.highlight("best", bold=False) + result = colors_always.highlight("best", bold=False) assert "\033[35m" in result # magenta foreground assert "\033[1m" not in result # no bold - subtle emphasis assert "best" in result -def test_shell_session_name_uses_info(monkeypatch: pytest.MonkeyPatch) -> None: +def test_shell_session_name_uses_info(colors_always: Colors) -> None: """Verify session name uses info color (cyan).""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - session_name = "dev-session" - result = colors.info(session_name) + result = colors_always.info(session_name) assert "\033[36m" in result # cyan foreground assert session_name in result -def test_shell_muted_for_static_text(monkeypatch: pytest.MonkeyPatch) -> None: +def test_shell_muted_for_static_text(colors_always: Colors) -> None: """Verify static text uses muted color (blue).""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - - result = colors.muted("Launching ") + result = colors_always.muted("Launching ") assert "\033[34m" in result # blue foreground assert "Launching" in result -def test_shell_colors_disabled_plain_text() -> None: +def test_shell_colors_disabled_plain_text(colors_never: Colors) -> None: """Verify disabled colors return plain text.""" - colors = Colors(ColorMode.NEVER) - shell_name = "ipython" session_name = "my-session" output = ( - colors.muted("Launching ") - + colors.highlight(shell_name, bold=False) - + colors.muted(" shell for session ") - + colors.info(session_name) - + colors.muted("...") + colors_never.muted("Launching ") + + colors_never.highlight(shell_name, bold=False) + + colors_never.muted(" shell for session ") + + colors_never.info(session_name) + + colors_never.muted("...") ) # Should be plain text without ANSI codes assert "\033[" not in output assert output == f"Launching {shell_name} shell for session {session_name}..." -def test_shell_various_shell_names(monkeypatch: pytest.MonkeyPatch) -> None: +def test_shell_various_shell_names(colors_always: Colors) -> None: """Verify all shell types can be highlighted.""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - shell_types = [ "best", "pdb", @@ -111,6 +89,6 @@ def test_shell_various_shell_names(monkeypatch: pytest.MonkeyPatch) -> None: "bpython", ] for shell_name in shell_types: - result = colors.highlight(shell_name, bold=False) + result = colors_always.highlight(shell_name, bold=False) assert "\033[35m" in result assert shell_name in result From 8e58e9cb1b9f3cd2b6c6d71e5eb1ed722aa615fc Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 10 Jan 2026 11:13:53 -0600 Subject: [PATCH 72/99] refactor(tests[cli]): Remove duplicate TestGetOutputMode class TestGetOutputMode was defined identically in both test_ls.py and test_output.py. Keep the tests in test_output.py where the function under test (get_output_mode) logically belongs. --- tests/cli/test_ls.py | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/tests/cli/test_ls.py b/tests/cli/test_ls.py index 89ed2a8662..ba9684a9b6 100644 --- a/tests/cli/test_ls.py +++ b/tests/cli/test_ls.py @@ -9,33 +9,12 @@ import pytest from tmuxp import cli -from tmuxp.cli._output import OutputMode, get_output_mode from tmuxp.cli.ls import ( _get_workspace_info, create_ls_subparser, ) -class TestGetOutputMode: - """Tests for output mode determination.""" - - def test_default_is_human(self) -> None: - """Default mode should be HUMAN when no flags.""" - assert get_output_mode(json_flag=False, ndjson_flag=False) == OutputMode.HUMAN - - def test_json_flag(self) -> None: - """JSON flag should return JSON mode.""" - assert get_output_mode(json_flag=True, ndjson_flag=False) == OutputMode.JSON - - def test_ndjson_flag(self) -> None: - """NDJSON flag should return NDJSON mode.""" - assert get_output_mode(json_flag=False, ndjson_flag=True) == OutputMode.NDJSON - - def test_ndjson_takes_precedence(self) -> None: - """NDJSON should take precedence when both flags set.""" - assert get_output_mode(json_flag=True, ndjson_flag=True) == OutputMode.NDJSON - - class TestWorkspaceInfo: """Tests for workspace info extraction.""" From 22d20a6174f1023ef3fc4d3dcc24764a1f627c83 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 10 Jan 2026 11:19:27 -0600 Subject: [PATCH 73/99] refactor(tests[cli]): Add ANSI color constants for readability Add semantic constants to tests/cli/conftest.py: - ANSI_GREEN, ANSI_RED, ANSI_YELLOW, ANSI_BLUE - ANSI_MAGENTA, ANSI_CYAN, ANSI_RESET, ANSI_BOLD Replace 80+ occurrences of magic ANSI escape strings like "\033[32m" with readable constants like ANSI_GREEN across 12 test files. --- tests/cli/conftest.py | 11 ++++++++ tests/cli/test_cli_colors_integration.py | 11 ++++---- tests/cli/test_colors.py | 36 +++++++++++++++--------- tests/cli/test_colors_formatters.py | 33 +++++++++++----------- tests/cli/test_convert_colors.py | 19 +++++++------ tests/cli/test_debug_info_colors.py | 23 +++++++-------- tests/cli/test_edit_colors.py | 17 +++++------ tests/cli/test_formatter.py | 3 +- tests/cli/test_freeze_colors.py | 32 +++++++++++++-------- tests/cli/test_import_colors.py | 29 +++++++++++-------- tests/cli/test_prompt_colors.py | 17 +++++------ tests/cli/test_shell_colors.py | 21 +++++++------- 12 files changed, 148 insertions(+), 104 deletions(-) diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py index 58f0b1d0cc..8acdfa2964 100644 --- a/tests/cli/conftest.py +++ b/tests/cli/conftest.py @@ -8,6 +8,17 @@ from tmuxp.cli._colors import ColorMode, Colors +# ANSI escape codes for test assertions +# These constants improve test readability by giving semantic names to color codes +ANSI_GREEN = "\033[32m" +ANSI_RED = "\033[31m" +ANSI_YELLOW = "\033[33m" +ANSI_BLUE = "\033[34m" +ANSI_MAGENTA = "\033[35m" +ANSI_CYAN = "\033[36m" +ANSI_RESET = "\033[0m" +ANSI_BOLD = "\033[1m" + @pytest.fixture def colors_always(monkeypatch: pytest.MonkeyPatch) -> Colors: diff --git a/tests/cli/test_cli_colors_integration.py b/tests/cli/test_cli_colors_integration.py index 2eab9d37af..28169dcfe1 100644 --- a/tests/cli/test_cli_colors_integration.py +++ b/tests/cli/test_cli_colors_integration.py @@ -7,6 +7,7 @@ import pytest +from tests.cli.conftest import ANSI_BOLD, ANSI_MAGENTA, ANSI_RESET from tmuxp.cli._colors import ColorMode, Colors, get_color_mode # Color flag integration tests @@ -125,7 +126,7 @@ def test_all_semantic_methods_respect_enabled_state( for method in methods: result = method("test") assert "\033[" in result, f"{method.__name__} should include ANSI codes" - assert result.endswith("\033[0m"), f"{method.__name__} should reset color" + assert result.endswith(ANSI_RESET), f"{method.__name__} should reset color" def test_all_semantic_methods_respect_disabled_state() -> None: @@ -153,11 +154,11 @@ def test_highlight_bold_parameter(monkeypatch: pytest.MonkeyPatch) -> None: with_bold = colors.highlight("test", bold=True) without_bold = colors.highlight("test", bold=False) - assert "\033[1m" in with_bold - assert "\033[1m" not in without_bold + assert ANSI_BOLD in with_bold + assert ANSI_BOLD not in without_bold # Both should have magenta - assert "\033[35m" in with_bold - assert "\033[35m" in without_bold + assert ANSI_MAGENTA in with_bold + assert ANSI_MAGENTA in without_bold # get_color_mode function tests diff --git a/tests/cli/test_colors.py b/tests/cli/test_colors.py index 3ffc75a3c5..d8d9b47c75 100644 --- a/tests/cli/test_colors.py +++ b/tests/cli/test_colors.py @@ -6,6 +6,16 @@ import pytest +from tests.cli.conftest import ( + ANSI_BLUE, + ANSI_BOLD, + ANSI_CYAN, + ANSI_GREEN, + ANSI_MAGENTA, + ANSI_RED, + ANSI_RESET, + ANSI_YELLOW, +) from tmuxp.cli._colors import ( ColorMode, Colors, @@ -95,9 +105,9 @@ def test_success_applies_green(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("NO_COLOR", raising=False) colors = Colors(ColorMode.ALWAYS) result = colors.success("ok") - assert "\033[32m" in result + assert ANSI_GREEN in result assert "ok" in result - assert result.endswith("\033[0m") + assert result.endswith(ANSI_RESET) def test_error_applies_red(monkeypatch: pytest.MonkeyPatch) -> None: @@ -105,7 +115,7 @@ def test_error_applies_red(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("NO_COLOR", raising=False) colors = Colors(ColorMode.ALWAYS) result = colors.error("fail") - assert "\033[31m" in result + assert ANSI_RED in result assert "fail" in result @@ -114,7 +124,7 @@ def test_warning_applies_yellow(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("NO_COLOR", raising=False) colors = Colors(ColorMode.ALWAYS) result = colors.warning("caution") - assert "\033[33m" in result + assert ANSI_YELLOW in result assert "caution" in result @@ -123,7 +133,7 @@ def test_info_applies_cyan(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("NO_COLOR", raising=False) colors = Colors(ColorMode.ALWAYS) result = colors.info("message") - assert "\033[36m" in result + assert ANSI_CYAN in result assert "message" in result @@ -132,8 +142,8 @@ def test_highlight_applies_magenta_bold(monkeypatch: pytest.MonkeyPatch) -> None monkeypatch.delenv("NO_COLOR", raising=False) colors = Colors(ColorMode.ALWAYS) result = colors.highlight("important") - assert "\033[35m" in result - assert "\033[1m" in result + assert ANSI_MAGENTA in result + assert ANSI_BOLD in result assert "important" in result @@ -142,8 +152,8 @@ def test_highlight_no_bold(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("NO_COLOR", raising=False) colors = Colors(ColorMode.ALWAYS) result = colors.highlight("important", bold=False) - assert "\033[35m" in result - assert "\033[1m" not in result + assert ANSI_MAGENTA in result + assert ANSI_BOLD not in result assert "important" in result @@ -152,8 +162,8 @@ def test_muted_applies_blue(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("NO_COLOR", raising=False) colors = Colors(ColorMode.ALWAYS) result = colors.muted("secondary") - assert "\033[34m" in result - assert "\033[1m" not in result + assert ANSI_BLUE in result + assert ANSI_BOLD not in result assert "secondary" in result @@ -162,8 +172,8 @@ def test_success_with_bold(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("NO_COLOR", raising=False) colors = Colors(ColorMode.ALWAYS) result = colors.success("done", bold=True) - assert "\033[32m" in result - assert "\033[1m" in result + assert ANSI_GREEN in result + assert ANSI_BOLD in result assert "done" in result diff --git a/tests/cli/test_colors_formatters.py b/tests/cli/test_colors_formatters.py index 86c8587708..deab92ff16 100644 --- a/tests/cli/test_colors_formatters.py +++ b/tests/cli/test_colors_formatters.py @@ -4,6 +4,7 @@ import pytest +from tests.cli.conftest import ANSI_BLUE, ANSI_BOLD, ANSI_CYAN, ANSI_MAGENTA from tmuxp.cli._colors import ColorMode, Colors # format_label tests @@ -21,8 +22,8 @@ def test_format_label_applies_highlight(monkeypatch: pytest.MonkeyPatch) -> None colors = Colors(ColorMode.ALWAYS) result = colors.format_label("tmux path") - assert "\033[35m" in result # magenta - assert "\033[1m" in result # bold + assert ANSI_MAGENTA in result # magenta + assert ANSI_BOLD in result # bold assert "tmux path" in result @@ -41,7 +42,7 @@ def test_format_path_applies_info(monkeypatch: pytest.MonkeyPatch) -> None: colors = Colors(ColorMode.ALWAYS) result = colors.format_path("/usr/bin/tmux") - assert "\033[36m" in result # cyan + assert ANSI_CYAN in result # cyan assert "/usr/bin/tmux" in result @@ -60,7 +61,7 @@ def test_format_version_applies_info(monkeypatch: pytest.MonkeyPatch) -> None: colors = Colors(ColorMode.ALWAYS) result = colors.format_version("3.2a") - assert "\033[36m" in result # cyan + assert ANSI_CYAN in result # cyan assert "3.2a" in result @@ -87,7 +88,7 @@ def test_format_separator_applies_muted(monkeypatch: pytest.MonkeyPatch) -> None colors = Colors(ColorMode.ALWAYS) result = colors.format_separator() - assert "\033[34m" in result # blue + assert ANSI_BLUE in result # blue assert "-" * 25 in result @@ -106,8 +107,8 @@ def test_format_kv_highlights_key(monkeypatch: pytest.MonkeyPatch) -> None: colors = Colors(ColorMode.ALWAYS) result = colors.format_kv("tmux version", "3.2a") - assert "\033[35m" in result # magenta for key - assert "\033[1m" in result # bold for key + assert ANSI_MAGENTA in result # magenta for key + assert ANSI_BOLD in result # bold for key assert "tmux version" in result assert ": 3.2a" in result @@ -139,8 +140,8 @@ def test_format_tmux_option_key_value_format(monkeypatch: pytest.MonkeyPatch) -> colors = Colors(ColorMode.ALWAYS) result = colors.format_tmux_option("base-index=1") - assert "\033[35m" in result # magenta for key - assert "\033[36m" in result # cyan for value + assert ANSI_MAGENTA in result # magenta for key + assert ANSI_CYAN in result # cyan for value assert "base-index" in result assert "=1" in result or "1" in result @@ -151,8 +152,8 @@ def test_format_tmux_option_space_separated(monkeypatch: pytest.MonkeyPatch) -> colors = Colors(ColorMode.ALWAYS) result = colors.format_tmux_option("status on") - assert "\033[35m" in result # magenta for key - assert "\033[36m" in result # cyan for value + assert ANSI_MAGENTA in result # magenta for key + assert ANSI_CYAN in result # cyan for value assert "status" in result assert "on" in result @@ -171,7 +172,7 @@ def test_format_tmux_option_single_word_highlighted( colors = Colors(ColorMode.ALWAYS) result = colors.format_tmux_option("pane-colours") - assert "\033[35m" in result # magenta for key + assert ANSI_MAGENTA in result # magenta for key assert "pane-colours" in result @@ -187,8 +188,8 @@ def test_format_tmux_option_array_indexed(monkeypatch: pytest.MonkeyPatch) -> No colors = Colors(ColorMode.ALWAYS) result = colors.format_tmux_option('status-format[0] "#[align=left]"') - assert "\033[35m" in result # magenta for key - assert "\033[36m" in result # cyan for value + assert ANSI_MAGENTA in result # magenta for key + assert ANSI_CYAN in result # cyan for value assert "status-format[0]" in result assert "#[align=left]" in result @@ -231,5 +232,5 @@ def test_format_tmux_option_value_with_equals( result = colors.format_tmux_option("option=a=b=c") assert "option" in result assert "a=b=c" in result - assert "\033[35m" in result # magenta for key - assert "\033[36m" in result # cyan for value + assert ANSI_MAGENTA in result # magenta for key + assert ANSI_CYAN in result # cyan for value diff --git a/tests/cli/test_convert_colors.py b/tests/cli/test_convert_colors.py index 8c24ca537e..5d395553a6 100644 --- a/tests/cli/test_convert_colors.py +++ b/tests/cli/test_convert_colors.py @@ -4,6 +4,7 @@ import pathlib +from tests.cli.conftest import ANSI_BOLD, ANSI_CYAN, ANSI_GREEN, ANSI_MAGENTA from tmuxp._internal.private_path import PrivatePath from tmuxp.cli._colors import Colors @@ -13,7 +14,7 @@ def test_convert_success_message(colors_always: Colors) -> None: """Verify success messages use success color (green).""" result = colors_always.success("New workspace file saved to ") - assert "\033[32m" in result # green foreground + assert ANSI_GREEN in result # green foreground assert "New workspace file saved to" in result @@ -21,7 +22,7 @@ def test_convert_file_path_uses_info(colors_always: Colors) -> None: """Verify file paths use info color (cyan).""" path = "/path/to/config.yaml" result = colors_always.info(path) - assert "\033[36m" in result # cyan foreground + assert ANSI_CYAN in result # cyan foreground assert path in result @@ -29,8 +30,8 @@ def test_convert_format_type_highlighted(colors_always: Colors) -> None: """Verify format type uses highlight color (magenta + bold).""" for fmt in ["json", "yaml"]: result = colors_always.highlight(fmt) - assert "\033[35m" in result # magenta foreground - assert "\033[1m" in result # bold + assert ANSI_MAGENTA in result # magenta foreground + assert ANSI_BOLD in result # bold assert fmt in result @@ -50,8 +51,8 @@ def test_convert_combined_success_format(colors_always: Colors) -> None: + "." ) # Should contain both green and cyan ANSI codes - assert "\033[32m" in output # green for success text - assert "\033[36m" in output # cyan for path + assert ANSI_GREEN in output # green for success text + assert ANSI_CYAN in output # cyan for path assert "New workspace file saved to" in output assert newfile in output assert output.endswith(".") @@ -65,8 +66,8 @@ def test_convert_prompt_format_with_highlight(colors_always: Colors) -> None: f"Convert {colors_always.info(workspace_file)} " f"to {colors_always.highlight(to_filetype)}?" ) - assert "\033[36m" in prompt # cyan for file path - assert "\033[35m" in prompt # magenta for format type + assert ANSI_CYAN in prompt # cyan for file path + assert ANSI_MAGENTA in prompt # magenta for format type assert workspace_file in prompt assert to_filetype in prompt @@ -75,7 +76,7 @@ def test_convert_save_prompt_format(colors_always: Colors) -> None: """Verify save prompt uses info color for new file path.""" newfile = "/path/to/config.json" prompt = f"Save workspace to {colors_always.info(newfile)}?" - assert "\033[36m" in prompt # cyan for file path + assert ANSI_CYAN in prompt # cyan for file path assert newfile in prompt assert "Save workspace to" in prompt diff --git a/tests/cli/test_debug_info_colors.py b/tests/cli/test_debug_info_colors.py index f1a553f3b9..ec2ac283db 100644 --- a/tests/cli/test_debug_info_colors.py +++ b/tests/cli/test_debug_info_colors.py @@ -6,6 +6,7 @@ import pytest +from tests.cli.conftest import ANSI_BLUE, ANSI_BOLD, ANSI_CYAN, ANSI_MAGENTA from tmuxp._internal.private_path import PrivatePath, collapse_home_in_string from tmuxp.cli._colors import ColorMode, Colors @@ -45,8 +46,8 @@ def test_debug_info_preserves_system_paths(mock_home: pathlib.Path) -> None: def test_debug_info_format_kv_labels(colors_always: Colors) -> None: """debug-info should highlight labels in key-value pairs.""" result = colors_always.format_kv("tmux version", "3.2a") - assert "\033[35m" in result # magenta for label - assert "\033[1m" in result # bold for label + assert ANSI_MAGENTA in result # magenta for label + assert ANSI_BOLD in result # bold for label assert "tmux version" in result assert "3.2a" in result @@ -56,7 +57,7 @@ def test_debug_info_format_version(colors_always: Colors) -> None: result = colors_always.format_kv( "tmux version", colors_always.format_version("3.2a") ) - assert "\033[36m" in result # cyan for version + assert ANSI_CYAN in result # cyan for version assert "3.2a" in result @@ -65,14 +66,14 @@ def test_debug_info_format_path(colors_always: Colors) -> None: result = colors_always.format_kv( "tmux path", colors_always.format_path("/usr/bin/tmux") ) - assert "\033[36m" in result # cyan for path + assert ANSI_CYAN in result # cyan for path assert "/usr/bin/tmux" in result def test_debug_info_format_separator(colors_always: Colors) -> None: """debug-info should use muted separators.""" result = colors_always.format_separator() - assert "\033[34m" in result # blue for muted + assert ANSI_BLUE in result # blue for muted assert "-" * 25 in result @@ -82,8 +83,8 @@ def test_debug_info_format_separator(colors_always: Colors) -> None: def test_debug_info_format_tmux_option_space_sep(colors_always: Colors) -> None: """debug-info should format space-separated tmux options.""" result = colors_always.format_tmux_option("status on") - assert "\033[35m" in result # magenta for key - assert "\033[36m" in result # cyan for value + assert ANSI_MAGENTA in result # magenta for key + assert ANSI_CYAN in result # cyan for value assert "status" in result assert "on" in result @@ -91,8 +92,8 @@ def test_debug_info_format_tmux_option_space_sep(colors_always: Colors) -> None: def test_debug_info_format_tmux_option_equals_sep(colors_always: Colors) -> None: """debug-info should format equals-separated tmux options.""" result = colors_always.format_tmux_option("base-index=0") - assert "\033[35m" in result # magenta for key - assert "\033[36m" in result # cyan for value + assert ANSI_MAGENTA in result # magenta for key + assert ANSI_CYAN in result # cyan for value assert "base-index" in result assert "0" in result @@ -134,8 +135,8 @@ def test_debug_info_combined_path_with_privacy( assert "~/work/tmuxp/src/tmuxp" in formatted assert "/home/testuser" not in formatted - assert "\033[36m" in formatted # cyan for path - assert "\033[35m" in formatted # magenta for label + assert ANSI_CYAN in formatted # cyan for path + assert ANSI_MAGENTA in formatted # magenta for label def test_debug_info_environment_section_format(colors_always: Colors) -> None: diff --git a/tests/cli/test_edit_colors.py b/tests/cli/test_edit_colors.py index 8b23caf97b..53c2663d80 100644 --- a/tests/cli/test_edit_colors.py +++ b/tests/cli/test_edit_colors.py @@ -4,6 +4,7 @@ import pathlib +from tests.cli.conftest import ANSI_BLUE, ANSI_BOLD, ANSI_CYAN, ANSI_MAGENTA from tmuxp._internal.private_path import PrivatePath from tmuxp.cli._colors import Colors @@ -22,9 +23,9 @@ def test_edit_opening_message_format(colors_always: Colors) -> None: + colors_always.muted("...") ) # Should contain blue, cyan, and magenta ANSI codes - assert "\033[34m" in output # blue for muted - assert "\033[36m" in output # cyan for file path - assert "\033[35m" in output # magenta for editor + assert ANSI_BLUE in output # blue for muted + assert ANSI_CYAN in output # cyan for file path + assert ANSI_MAGENTA in output # magenta for editor assert workspace_file in output assert editor in output @@ -33,7 +34,7 @@ def test_edit_file_path_uses_info(colors_always: Colors) -> None: """Verify file paths use info color (cyan).""" path = "/path/to/workspace.yaml" result = colors_always.info(path) - assert "\033[36m" in result # cyan foreground + assert ANSI_CYAN in result # cyan foreground assert path in result @@ -41,15 +42,15 @@ def test_edit_editor_highlighted(colors_always: Colors) -> None: """Verify editor name uses highlight color without bold.""" for editor in ["vim", "nano", "code", "emacs", "nvim"]: result = colors_always.highlight(editor, bold=False) - assert "\033[35m" in result # magenta foreground - assert "\033[1m" not in result # no bold - subtle + assert ANSI_MAGENTA in result # magenta foreground + assert ANSI_BOLD not in result # no bold - subtle assert editor in result def test_edit_muted_for_static_text(colors_always: Colors) -> None: """Verify static text uses muted color (blue).""" result = colors_always.muted("Opening ") - assert "\033[34m" in result # blue foreground + assert ANSI_BLUE in result # blue foreground assert "Opening" in result @@ -74,7 +75,7 @@ def test_edit_various_editors(colors_always: Colors) -> None: editors = ["vim", "nvim", "nano", "code", "emacs", "hx", "micro"] for editor in editors: result = colors_always.highlight(editor, bold=False) - assert "\033[35m" in result + assert ANSI_MAGENTA in result assert editor in result diff --git a/tests/cli/test_formatter.py b/tests/cli/test_formatter.py index ecd16dbb17..0670664941 100644 --- a/tests/cli/test_formatter.py +++ b/tests/cli/test_formatter.py @@ -6,6 +6,7 @@ import pytest +from tests.cli.conftest import ANSI_RESET from tmuxp.cli._colors import ColorMode, Colors from tmuxp.cli._formatter import ( HelpTheme, @@ -183,4 +184,4 @@ def test_from_colors_enabled_returns_colored_theme( # Should have ANSI codes assert "\033[" in theme.prog assert "\033[" in theme.action - assert theme.reset == "\033[0m" + assert theme.reset == ANSI_RESET diff --git a/tests/cli/test_freeze_colors.py b/tests/cli/test_freeze_colors.py index 7c36459175..3d6afaa012 100644 --- a/tests/cli/test_freeze_colors.py +++ b/tests/cli/test_freeze_colors.py @@ -4,6 +4,14 @@ import pathlib +from tests.cli.conftest import ( + ANSI_BLUE, + ANSI_CYAN, + ANSI_GREEN, + ANSI_RED, + ANSI_RESET, + ANSI_YELLOW, +) from tmuxp._internal.private_path import PrivatePath from tmuxp.cli._colors import Colors @@ -14,15 +22,15 @@ def test_freeze_error_uses_red(colors_always: Colors) -> None: """Verify error messages use error color (red).""" msg = "Session not found" result = colors_always.error(msg) - assert "\033[31m" in result # red foreground + assert ANSI_RED in result # red foreground assert msg in result - assert result.endswith("\033[0m") # reset at end + assert result.endswith(ANSI_RESET) # reset at end def test_freeze_success_message(colors_always: Colors) -> None: """Verify success messages use success color (green).""" result = colors_always.success("Saved to ") - assert "\033[32m" in result # green foreground + assert ANSI_GREEN in result # green foreground assert "Saved to" in result @@ -30,7 +38,7 @@ def test_freeze_file_path_uses_info(colors_always: Colors) -> None: """Verify file paths use info color (cyan).""" path = "/path/to/config.yaml" result = colors_always.info(path) - assert "\033[36m" in result # cyan foreground + assert ANSI_CYAN in result # cyan foreground assert path in result @@ -38,7 +46,7 @@ def test_freeze_warning_file_exists(colors_always: Colors) -> None: """Verify file exists warning uses warning color (yellow).""" msg = "/path/to/config.yaml exists." result = colors_always.warning(msg) - assert "\033[33m" in result # yellow foreground + assert ANSI_YELLOW in result # yellow foreground assert msg in result @@ -46,7 +54,7 @@ def test_freeze_muted_for_secondary_text(colors_always: Colors) -> None: """Verify secondary text uses muted color (blue).""" msg = "Freeze does its best to snapshot live tmux sessions." result = colors_always.muted(msg) - assert "\033[34m" in result # blue foreground + assert ANSI_BLUE in result # blue foreground assert msg in result @@ -64,8 +72,8 @@ def test_freeze_combined_output_format(colors_always: Colors) -> None: dest = "/home/user/.tmuxp/session.yaml" output = colors_always.success("Saved to ") + colors_always.info(dest) + "." # Should contain both green and cyan ANSI codes - assert "\033[32m" in output # green for "Saved to" - assert "\033[36m" in output # cyan for path + assert ANSI_GREEN in output # green for "Saved to" + assert ANSI_CYAN in output # cyan for path assert "Saved to" in output assert dest in output assert output.endswith(".") @@ -80,8 +88,8 @@ def test_freeze_warning_with_instructions(colors_always: Colors) -> None: + colors_always.muted("Pick a new filename.") ) # Should contain both yellow and blue ANSI codes - assert "\033[33m" in output # yellow for warning - assert "\033[34m" in output # blue for muted + assert ANSI_YELLOW in output # yellow for warning + assert ANSI_BLUE in output # blue for muted assert path in output assert "Pick a new filename." in output @@ -90,8 +98,8 @@ def test_freeze_url_highlighted_in_help(colors_always: Colors) -> None: """Verify URLs use info color in help text.""" url = "" help_text = colors_always.muted("tmuxp has examples at ") + colors_always.info(url) - assert "\033[34m" in help_text # blue for muted text - assert "\033[36m" in help_text # cyan for URL + assert ANSI_BLUE in help_text # blue for muted text + assert ANSI_CYAN in help_text # cyan for URL assert url in help_text diff --git a/tests/cli/test_import_colors.py b/tests/cli/test_import_colors.py index eb8b829ca9..158e69b83f 100644 --- a/tests/cli/test_import_colors.py +++ b/tests/cli/test_import_colors.py @@ -4,6 +4,13 @@ import pathlib +from tests.cli.conftest import ( + ANSI_BLUE, + ANSI_CYAN, + ANSI_GREEN, + ANSI_RED, + ANSI_RESET, +) from tmuxp._internal.private_path import PrivatePath from tmuxp.cli._colors import Colors @@ -14,15 +21,15 @@ def test_import_error_unknown_format(colors_always: Colors) -> None: """Verify unknown format error uses error color (red).""" msg = "Unknown config format." result = colors_always.error(msg) - assert "\033[31m" in result # red foreground + assert ANSI_RED in result # red foreground assert msg in result - assert result.endswith("\033[0m") # reset at end + assert result.endswith(ANSI_RESET) # reset at end def test_import_success_message(colors_always: Colors) -> None: """Verify success messages use success color (green).""" result = colors_always.success("Saved to ") - assert "\033[32m" in result # green foreground + assert ANSI_GREEN in result # green foreground assert "Saved to" in result @@ -30,7 +37,7 @@ def test_import_file_path_uses_info(colors_always: Colors) -> None: """Verify file paths use info color (cyan).""" path = "/path/to/config.yaml" result = colors_always.info(path) - assert "\033[36m" in result # cyan foreground + assert ANSI_CYAN in result # cyan foreground assert path in result @@ -38,7 +45,7 @@ def test_import_muted_for_banner(colors_always: Colors) -> None: """Verify banner text uses muted color (blue).""" msg = "Configuration import does its best to convert files." result = colors_always.muted(msg) - assert "\033[34m" in result # blue foreground + assert ANSI_BLUE in result # blue foreground assert msg in result @@ -46,7 +53,7 @@ def test_import_muted_for_separator(colors_always: Colors) -> None: """Verify separator uses muted color (blue).""" separator = "---------------------------------------------------------------" result = colors_always.muted(separator) - assert "\033[34m" in result # blue foreground + assert ANSI_BLUE in result # blue foreground assert separator in result @@ -63,8 +70,8 @@ def test_import_combined_success_format(colors_always: Colors) -> None: dest = "/home/user/.tmuxp/session.yaml" output = colors_always.success("Saved to ") + colors_always.info(dest) + "." # Should contain both green and cyan ANSI codes - assert "\033[32m" in output # green for "Saved to" - assert "\033[36m" in output # cyan for path + assert ANSI_GREEN in output # green for "Saved to" + assert ANSI_CYAN in output # cyan for path assert "Saved to" in output assert dest in output assert output.endswith(".") @@ -76,8 +83,8 @@ def test_import_help_text_with_urls(colors_always: Colors) -> None: help_text = colors_always.muted( "tmuxp has examples in JSON and YAML format at " ) + colors_always.info(url) - assert "\033[34m" in help_text # blue for muted text - assert "\033[36m" in help_text # cyan for URL + assert ANSI_BLUE in help_text # blue for muted text + assert ANSI_CYAN in help_text # cyan for URL assert url in help_text @@ -93,7 +100,7 @@ def test_import_banner_with_separator(colors_always: Colors) -> None: + "\n" ) # Should contain blue ANSI code for muted sections - assert "\033[34m" in output + assert ANSI_BLUE in output assert separator in output assert "Configuration import" in output assert config_content in output diff --git a/tests/cli/test_prompt_colors.py b/tests/cli/test_prompt_colors.py index 738761cae4..65a7093f65 100644 --- a/tests/cli/test_prompt_colors.py +++ b/tests/cli/test_prompt_colors.py @@ -6,6 +6,7 @@ import pytest +from tests.cli.conftest import ANSI_BLUE, ANSI_CYAN, ANSI_RESET from tmuxp.cli._colors import ColorMode, Colors @@ -13,16 +14,16 @@ def test_prompt_bool_choice_indicator_muted(colors_always: Colors) -> None: """Verify [Y/n] uses muted color (blue).""" # Test the muted color is applied to choice indicators result = colors_always.muted("[Y/n]") - assert "\033[34m" in result # blue foreground + assert ANSI_BLUE in result # blue foreground assert "[Y/n]" in result - assert result.endswith("\033[0m") + assert result.endswith(ANSI_RESET) def test_prompt_bool_choice_indicator_variants(colors_always: Colors) -> None: """Verify all choice indicator variants are colored.""" for indicator in ["[Y/n]", "[y/N]", "[y/n]"]: result = colors_always.muted(indicator) - assert "\033[34m" in result + assert ANSI_BLUE in result assert indicator in result @@ -30,16 +31,16 @@ def test_prompt_default_value_uses_info(colors_always: Colors) -> None: """Verify default path uses info color (cyan).""" path = "/home/user/.tmuxp/session.yaml" result = colors_always.info(f"[{path}]") - assert "\033[36m" in result # cyan foreground + assert ANSI_CYAN in result # cyan foreground assert path in result - assert result.endswith("\033[0m") + assert result.endswith(ANSI_RESET) def test_prompt_choices_list_muted(colors_always: Colors) -> None: """Verify (yaml, json) uses muted color (blue).""" choices = "(yaml, json)" result = colors_always.muted(choices) - assert "\033[34m" in result # blue foreground + assert ANSI_BLUE in result # blue foreground assert choices in result @@ -60,8 +61,8 @@ def test_prompt_combined_format(colors_always: Colors) -> None: prompt = f"{name} - {choices_str} {default_str}" # Should contain both blue (muted) and cyan (info) ANSI codes - assert "\033[34m" in prompt # blue for choices - assert "\033[36m" in prompt # cyan for default + assert ANSI_BLUE in prompt # blue for choices + assert ANSI_CYAN in prompt # cyan for default assert "Convert to" in prompt assert "yaml, json" in prompt diff --git a/tests/cli/test_shell_colors.py b/tests/cli/test_shell_colors.py index 0bce85083d..de92e748ae 100644 --- a/tests/cli/test_shell_colors.py +++ b/tests/cli/test_shell_colors.py @@ -2,6 +2,7 @@ from __future__ import annotations +from tests.cli.conftest import ANSI_BLUE, ANSI_BOLD, ANSI_CYAN, ANSI_MAGENTA from tmuxp.cli._colors import Colors # Shell command color output tests @@ -19,9 +20,9 @@ def test_shell_launch_message_format(colors_always: Colors) -> None: + colors_always.muted("...") ) # Should contain blue, magenta, and cyan ANSI codes - assert "\033[34m" in output # blue for muted - assert "\033[35m" in output # magenta for highlight - assert "\033[36m" in output # cyan for session name + assert ANSI_BLUE in output # blue for muted + assert ANSI_MAGENTA in output # magenta for highlight + assert ANSI_CYAN in output # cyan for session name assert shell_name in output assert session_name in output @@ -33,16 +34,16 @@ def test_shell_pdb_launch_message(colors_always: Colors) -> None: + colors_always.highlight("pdb", bold=False) + colors_always.muted(" shell...") ) - assert "\033[34m" in output # blue for muted - assert "\033[35m" in output # magenta for pdb + assert ANSI_BLUE in output # blue for muted + assert ANSI_MAGENTA in output # magenta for pdb assert "pdb" in output def test_shell_highlight_not_bold(colors_always: Colors) -> None: """Verify shell name uses highlight without bold for subtlety.""" result = colors_always.highlight("best", bold=False) - assert "\033[35m" in result # magenta foreground - assert "\033[1m" not in result # no bold - subtle emphasis + assert ANSI_MAGENTA in result # magenta foreground + assert ANSI_BOLD not in result # no bold - subtle emphasis assert "best" in result @@ -50,14 +51,14 @@ def test_shell_session_name_uses_info(colors_always: Colors) -> None: """Verify session name uses info color (cyan).""" session_name = "dev-session" result = colors_always.info(session_name) - assert "\033[36m" in result # cyan foreground + assert ANSI_CYAN in result # cyan foreground assert session_name in result def test_shell_muted_for_static_text(colors_always: Colors) -> None: """Verify static text uses muted color (blue).""" result = colors_always.muted("Launching ") - assert "\033[34m" in result # blue foreground + assert ANSI_BLUE in result # blue foreground assert "Launching" in result @@ -90,5 +91,5 @@ def test_shell_various_shell_names(colors_always: Colors) -> None: ] for shell_name in shell_types: result = colors_always.highlight(shell_name, bold=False) - assert "\033[35m" in result + assert ANSI_MAGENTA in result assert shell_name in result From ce284311c4c1c9334f0baada19fdc49c1d037b39 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 10 Jan 2026 11:23:52 -0600 Subject: [PATCH 74/99] tests(cli): add isolated_home fixture, refactor test_ls.py Consolidate repeated 5-line HOME isolation pattern into a reusable fixture. Refactors 10 tests in TestLsCli and TestLsFullFlag to use the new fixture, eliminating 88 lines of boilerplate setup code. Tests using nested home directories (e.g., home = tmp_path / "home") for parent directory traversal are preserved as-is, since they need custom directory structures for local workspace discovery tests. --- tests/cli/conftest.py | 18 +++++++ tests/cli/test_ls.py | 108 ++++++++---------------------------------- 2 files changed, 38 insertions(+), 88 deletions(-) diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py index 8acdfa2964..63706e2a41 100644 --- a/tests/cli/conftest.py +++ b/tests/cli/conftest.py @@ -42,3 +42,21 @@ def mock_home(monkeypatch: pytest.MonkeyPatch) -> pathlib.Path: home = pathlib.Path("/home/testuser") monkeypatch.setattr(pathlib.Path, "home", lambda: home) return home + + +@pytest.fixture +def isolated_home( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, +) -> pathlib.Path: + """Isolate test from user's home directory and environment. + + Sets up tmp_path as HOME with XDG_CONFIG_HOME, clears NO_COLOR, + and changes the working directory to tmp_path. + """ + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / ".config")) + monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(pathlib.Path, "home", lambda: tmp_path) + return tmp_path diff --git a/tests/cli/test_ls.py b/tests/cli/test_ls.py index ba9684a9b6..d0dd0cc8f8 100644 --- a/tests/cli/test_ls.py +++ b/tests/cli/test_ls.py @@ -114,16 +114,10 @@ class TestLsCli: def test_ls_cli( self, - monkeypatch: pytest.MonkeyPatch, - tmp_path: pathlib.Path, + isolated_home: pathlib.Path, capsys: pytest.CaptureFixture[str], ) -> None: """CLI test for tmuxp ls.""" - monkeypatch.setenv("HOME", str(tmp_path)) - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / ".config")) - monkeypatch.chdir(tmp_path) - monkeypatch.setattr(pathlib.Path, "home", lambda: tmp_path) - filenames = [ ".git/", ".gitignore/", @@ -140,7 +134,7 @@ def test_ls_cli( stems = [pathlib.Path(f).stem for f in filenames if f not in ignored_filenames] for filename in filenames: - location = tmp_path / f".tmuxp/{filename}" + location = isolated_home / f".tmuxp/{filename}" if filename.endswith("/"): location.mkdir(parents=True) else: @@ -158,18 +152,11 @@ def test_ls_cli( def test_ls_json_output( self, - monkeypatch: pytest.MonkeyPatch, - tmp_path: pathlib.Path, + isolated_home: pathlib.Path, capsys: pytest.CaptureFixture[str], ) -> None: """CLI test for tmuxp ls --json.""" - monkeypatch.setenv("HOME", str(tmp_path)) - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / ".config")) - monkeypatch.delenv("NO_COLOR", raising=False) - monkeypatch.chdir(tmp_path) - monkeypatch.setattr(pathlib.Path, "home", lambda: tmp_path) - - tmuxp_dir = tmp_path / ".tmuxp" + tmuxp_dir = isolated_home / ".tmuxp" tmuxp_dir.mkdir(parents=True) (tmuxp_dir / "dev.yaml").write_text("session_name: development\nwindows: []") (tmuxp_dir / "prod.json").write_text('{"session_name": "production"}') @@ -204,18 +191,11 @@ def test_ls_json_output( def test_ls_ndjson_output( self, - monkeypatch: pytest.MonkeyPatch, - tmp_path: pathlib.Path, + isolated_home: pathlib.Path, capsys: pytest.CaptureFixture[str], ) -> None: """CLI test for tmuxp ls --ndjson.""" - monkeypatch.setenv("HOME", str(tmp_path)) - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / ".config")) - monkeypatch.delenv("NO_COLOR", raising=False) - monkeypatch.chdir(tmp_path) - monkeypatch.setattr(pathlib.Path, "home", lambda: tmp_path) - - tmuxp_dir = tmp_path / ".tmuxp" + tmuxp_dir = isolated_home / ".tmuxp" tmuxp_dir.mkdir(parents=True) (tmuxp_dir / "ws1.yaml").write_text("session_name: s1\nwindows: []") (tmuxp_dir / "ws2.yaml").write_text("session_name: s2\nwindows: []") @@ -237,18 +217,11 @@ def test_ls_ndjson_output( def test_ls_tree_output( self, - monkeypatch: pytest.MonkeyPatch, - tmp_path: pathlib.Path, + isolated_home: pathlib.Path, capsys: pytest.CaptureFixture[str], ) -> None: """CLI test for tmuxp ls --tree.""" - monkeypatch.setenv("HOME", str(tmp_path)) - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / ".config")) - monkeypatch.delenv("NO_COLOR", raising=False) - monkeypatch.chdir(tmp_path) - monkeypatch.setattr(pathlib.Path, "home", lambda: tmp_path) - - tmuxp_dir = tmp_path / ".tmuxp" + tmuxp_dir = isolated_home / ".tmuxp" tmuxp_dir.mkdir(parents=True) (tmuxp_dir / "dev.yaml").write_text("session_name: development\nwindows: []") @@ -264,18 +237,11 @@ def test_ls_tree_output( def test_ls_empty_directory( self, - monkeypatch: pytest.MonkeyPatch, - tmp_path: pathlib.Path, + isolated_home: pathlib.Path, capsys: pytest.CaptureFixture[str], ) -> None: """CLI test for tmuxp ls with no workspaces.""" - monkeypatch.setenv("HOME", str(tmp_path)) - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / ".config")) - monkeypatch.delenv("NO_COLOR", raising=False) - monkeypatch.chdir(tmp_path) - monkeypatch.setattr(pathlib.Path, "home", lambda: tmp_path) - - tmuxp_dir = tmp_path / ".tmuxp" + tmuxp_dir = isolated_home / ".tmuxp" tmuxp_dir.mkdir(parents=True) with contextlib.suppress(SystemExit): @@ -286,18 +252,11 @@ def test_ls_empty_directory( def test_ls_tree_shows_session_name_if_different( self, - monkeypatch: pytest.MonkeyPatch, - tmp_path: pathlib.Path, + isolated_home: pathlib.Path, capsys: pytest.CaptureFixture[str], ) -> None: """Tree mode shows session_name if it differs from file name.""" - monkeypatch.setenv("HOME", str(tmp_path)) - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / ".config")) - monkeypatch.delenv("NO_COLOR", raising=False) - monkeypatch.chdir(tmp_path) - monkeypatch.setattr(pathlib.Path, "home", lambda: tmp_path) - - tmuxp_dir = tmp_path / ".tmuxp" + tmuxp_dir = isolated_home / ".tmuxp" tmuxp_dir.mkdir(parents=True) # File named "myfile" but session is "actual-session" (tmuxp_dir / "myfile.yaml").write_text( @@ -510,17 +469,11 @@ def test_get_workspace_info_no_config_by_default( def test_ls_json_full_includes_config( self, - monkeypatch: pytest.MonkeyPatch, - tmp_path: pathlib.Path, + isolated_home: pathlib.Path, capsys: pytest.CaptureFixture[str], ) -> None: """JSON output with --full includes config content.""" - monkeypatch.setenv("HOME", str(tmp_path)) - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / ".config")) - monkeypatch.chdir(tmp_path) - monkeypatch.setattr(pathlib.Path, "home", lambda: tmp_path) - - tmuxp_dir = tmp_path / ".tmuxp" + tmuxp_dir = isolated_home / ".tmuxp" tmuxp_dir.mkdir(parents=True) (tmuxp_dir / "dev.yaml").write_text( "session_name: dev\n" @@ -547,18 +500,11 @@ def test_ls_json_full_includes_config( def test_ls_full_tree_shows_windows( self, - monkeypatch: pytest.MonkeyPatch, - tmp_path: pathlib.Path, + isolated_home: pathlib.Path, capsys: pytest.CaptureFixture[str], ) -> None: """Tree mode with --full shows window/pane hierarchy.""" - monkeypatch.setenv("HOME", str(tmp_path)) - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / ".config")) - monkeypatch.chdir(tmp_path) - monkeypatch.setattr(pathlib.Path, "home", lambda: tmp_path) - monkeypatch.delenv("NO_COLOR", raising=False) - - tmuxp_dir = tmp_path / ".tmuxp" + tmuxp_dir = isolated_home / ".tmuxp" tmuxp_dir.mkdir(parents=True) (tmuxp_dir / "dev.yaml").write_text( "session_name: dev\n" @@ -583,18 +529,11 @@ def test_ls_full_tree_shows_windows( def test_ls_full_flat_shows_windows( self, - monkeypatch: pytest.MonkeyPatch, - tmp_path: pathlib.Path, + isolated_home: pathlib.Path, capsys: pytest.CaptureFixture[str], ) -> None: """Flat mode with --full shows window/pane hierarchy.""" - monkeypatch.setenv("HOME", str(tmp_path)) - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / ".config")) - monkeypatch.chdir(tmp_path) - monkeypatch.setattr(pathlib.Path, "home", lambda: tmp_path) - monkeypatch.delenv("NO_COLOR", raising=False) - - tmuxp_dir = tmp_path / ".tmuxp" + tmuxp_dir = isolated_home / ".tmuxp" tmuxp_dir.mkdir(parents=True) (tmuxp_dir / "dev.yaml").write_text( "session_name: dev\n" @@ -616,18 +555,11 @@ def test_ls_full_flat_shows_windows( def test_ls_full_without_json_no_config_in_output( self, - monkeypatch: pytest.MonkeyPatch, - tmp_path: pathlib.Path, + isolated_home: pathlib.Path, capsys: pytest.CaptureFixture[str], ) -> None: """Non-JSON with --full shows tree but not raw config.""" - monkeypatch.setenv("HOME", str(tmp_path)) - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / ".config")) - monkeypatch.chdir(tmp_path) - monkeypatch.setattr(pathlib.Path, "home", lambda: tmp_path) - monkeypatch.delenv("NO_COLOR", raising=False) - - tmuxp_dir = tmp_path / ".tmuxp" + tmuxp_dir = isolated_home / ".tmuxp" tmuxp_dir.mkdir(parents=True) (tmuxp_dir / "dev.yaml").write_text( "session_name: dev\nwindows:\n - window_name: editor\n" From 026e6987d4ba13d89d69f7566ed69e474db958d8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 10 Jan 2026 12:10:09 -0600 Subject: [PATCH 75/99] fix(cli): resolve bold/dim ANSI code conflict in style() ANSI code 22 ("normal intensity") resets both bold AND dim. The previous implementation would emit code 22 when bold=False or dim=False, which could cancel out the other setting. Simplify to only emit "on" codes (1 for bold, 2 for dim) when True. The reset=True default handles turning off styles at the end. --- src/tmuxp/cli/_colors.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/tmuxp/cli/_colors.py b/src/tmuxp/cli/_colors.py index bb3fd7ae7f..ece4d4adcd 100644 --- a/src/tmuxp/cli/_colors.py +++ b/src/tmuxp/cli/_colors.py @@ -762,10 +762,10 @@ def style( except (KeyError, ValueError): raise UnknownStyleColor(color=bg) from None - if bold is not None: - bits.append(f"\033[{1 if bold else 22}m") - if dim is not None: - bits.append(f"\033[{2 if dim else 22}m") + if bold: + bits.append("\033[1m") + if dim: + bits.append("\033[2m") if underline is not None: bits.append(f"\033[{4 if underline else 24}m") if overline is not None: From 42b054ccdbf2c3b9d22d1a063cc51306285227cd Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 10 Jan 2026 12:11:30 -0600 Subject: [PATCH 76/99] refactor(workspace): replace colorama with Colors class in finders.py Use the new semantic Colors class for error messages instead of raw colorama.Fore.RED. This ensures consistent color handling across the codebase and respects NO_COLOR/FORCE_COLOR environment variables. --- src/tmuxp/workspace/finders.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/tmuxp/workspace/finders.py b/src/tmuxp/workspace/finders.py index 00e01b50b0..4166498964 100644 --- a/src/tmuxp/workspace/finders.py +++ b/src/tmuxp/workspace/finders.py @@ -7,9 +7,8 @@ import pathlib import typing as t -from colorama import Fore - from tmuxp._internal.private_path import PrivatePath +from tmuxp.cli._colors import ColorMode, Colors from tmuxp.cli.utils import tmuxp_echo from tmuxp.workspace.constants import VALID_WORKSPACE_DIR_FILE_EXTENSIONS @@ -362,11 +361,12 @@ def find_workspace_file( ] if len(candidates) > 1: + colors = Colors(ColorMode.AUTO) tmuxp_echo( - Fore.RED - + "Multiple .tmuxp.{yml,yaml,json} workspace_files in " - + dirname(workspace_file) - + Fore.RESET, + colors.error( + "Multiple .tmuxp.{yml,yaml,json} workspace_files in " + + dirname(workspace_file) + ), ) tmuxp_echo( "This is undefined behavior, use only one. " From 72308106faccdb2fc036be8517509e19453f30e4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 10 Jan 2026 13:05:51 -0600 Subject: [PATCH 77/99] _internal/colors(refactor): Move colors module from cli/ to _internal/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Colors module was in cli/ but used by workspace/finders.py, creating a reverse dependency (workspace→cli). _internal/ is the proper layer for shared utilities. what: - Move src/tmuxp/cli/_colors.py → src/tmuxp/_internal/colors.py - Replace cli/_colors.py with backward-compatible re-export wrapper - Update internal imports in cli/utils.py, workspace/finders.py - Update test imports in tests/cli/conftest.py --- src/tmuxp/_internal/colors.py | 859 ++++++++++++++++++++++++++++++++ src/tmuxp/cli/_colors.py | 879 +-------------------------------- src/tmuxp/cli/utils.py | 5 +- src/tmuxp/workspace/finders.py | 2 +- tests/cli/conftest.py | 2 +- 5 files changed, 889 insertions(+), 858 deletions(-) create mode 100644 src/tmuxp/_internal/colors.py diff --git a/src/tmuxp/_internal/colors.py b/src/tmuxp/_internal/colors.py new file mode 100644 index 0000000000..7ff53c3edb --- /dev/null +++ b/src/tmuxp/_internal/colors.py @@ -0,0 +1,859 @@ +"""Color output utilities for tmuxp CLI. + +This module provides semantic color utilities following patterns from vcspull +and CPython's _colorize module. It includes low-level ANSI styling functions +and high-level semantic color utilities. + +Examples +-------- +Basic usage with automatic TTY detection (AUTO mode is the default). +In a TTY, colored text is returned; otherwise plain text: + +>>> colors = Colors() + +Force colors on or off: + +>>> colors = Colors(ColorMode.ALWAYS) +>>> colors.success("loaded") # doctest: +ELLIPSIS +'...' + +>>> colors = Colors(ColorMode.NEVER) +>>> colors.success("loaded") +'loaded' + +Environment variables NO_COLOR and FORCE_COLOR are respected. +NO_COLOR takes highest priority (disables even in ALWAYS mode): + +>>> monkeypatch.setenv("NO_COLOR", "1") +>>> colors = Colors(ColorMode.ALWAYS) +>>> colors.success("loaded") +'loaded' + +FORCE_COLOR enables colors in AUTO mode even without TTY: + +>>> import sys +>>> monkeypatch.delenv("NO_COLOR", raising=False) +>>> monkeypatch.setenv("FORCE_COLOR", "1") +>>> monkeypatch.setattr(sys.stdout, "isatty", lambda: False) +>>> colors = Colors(ColorMode.AUTO) +>>> colors.success("loaded") # doctest: +ELLIPSIS +'...' +""" + +from __future__ import annotations + +import enum +import os +import re +import sys +import typing as t + +if t.TYPE_CHECKING: + from typing import TypeAlias + + CLIColour: TypeAlias = int | tuple[int, int, int] | str + + +class ColorMode(enum.Enum): + """Color output modes for CLI. + + Examples + -------- + >>> ColorMode.AUTO.value + 'auto' + >>> ColorMode.ALWAYS.value + 'always' + >>> ColorMode.NEVER.value + 'never' + """ + + AUTO = "auto" + ALWAYS = "always" + NEVER = "never" + + +class Colors: + r"""Semantic color utilities for CLI output. + + Provides semantic color methods (success, warning, error, etc.) that + conditionally apply ANSI colors based on the color mode and environment. + + Parameters + ---------- + mode : ColorMode + Color mode to use. Default is AUTO which detects TTY. + + Attributes + ---------- + SUCCESS : str + Color name for success messages (green) + WARNING : str + Color name for warning messages (yellow) + ERROR : str + Color name for error messages (red) + INFO : str + Color name for informational messages (cyan) + HIGHLIGHT : str + Color name for highlighted/important text (magenta) + MUTED : str + Color name for subdued/secondary text (blue) + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.success("session loaded") + 'session loaded' + >>> colors.error("failed to load") + 'failed to load' + + >>> colors = Colors(ColorMode.ALWAYS) + >>> result = colors.success("ok") + + Check that result contains ANSI escape codes: + + >>> "\033[" in result + True + """ + + # Semantic color names (used with style()) + SUCCESS = "green" # Success, loaded, up-to-date + WARNING = "yellow" # Warnings, changes needed + ERROR = "red" # Errors, failures + INFO = "cyan" # Information, paths + HIGHLIGHT = "magenta" # Important labels, session names + MUTED = "blue" # Subdued info, secondary text + + def __init__(self, mode: ColorMode = ColorMode.AUTO) -> None: + """Initialize color manager. + + Parameters + ---------- + mode : ColorMode + Color mode to use (auto, always, never). Default is AUTO. + + Examples + -------- + >>> colors = Colors() + >>> colors.mode + + + >>> colors = Colors(ColorMode.NEVER) + >>> colors._enabled + False + """ + self.mode = mode + self._enabled = self._should_enable() + + def _should_enable(self) -> bool: + """Determine if color should be enabled. + + Follows CPython-style precedence: + 1. NO_COLOR env var (any value) -> disable + 2. ColorMode.NEVER -> disable + 3. ColorMode.ALWAYS -> enable + 4. FORCE_COLOR env var (any value) -> enable + 5. TTY check -> enable if stdout is a terminal + + Returns + ------- + bool + True if colors should be enabled. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors._should_enable() + False + """ + # NO_COLOR takes highest priority (standard convention) + if os.environ.get("NO_COLOR"): + return False + + if self.mode == ColorMode.NEVER: + return False + if self.mode == ColorMode.ALWAYS: + return True + + # AUTO mode: check FORCE_COLOR then TTY + if os.environ.get("FORCE_COLOR"): + return True + + return sys.stdout.isatty() + + def _colorize( + self, text: str, fg: str, bold: bool = False, dim: bool = False + ) -> str: + """Apply color using style() function. + + Parameters + ---------- + text : str + Text to colorize. + fg : str + Foreground color name (e.g., "green", "red"). + bold : bool + Whether to apply bold style. Default is False. + dim : bool + Whether to apply dim/faint style. Default is False. + + Returns + ------- + str + Colorized text if enabled, plain text otherwise. + + Examples + -------- + When colors are enabled, applies ANSI escape codes: + + >>> colors = Colors(ColorMode.ALWAYS) + >>> colors._colorize("test", "green") # doctest: +ELLIPSIS + '...' + + When colors are disabled, returns plain text: + + >>> colors = Colors(ColorMode.NEVER) + >>> colors._colorize("test", "green") + 'test' + """ + if self._enabled: + return style(text, fg=fg, bold=bold, dim=dim) + return text + + def success(self, text: str, bold: bool = False) -> str: + """Format text as success (green). + + Parameters + ---------- + text : str + Text to format. + bold : bool + Whether to apply bold style. Default is False. + + Returns + ------- + str + Formatted text. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.success("loaded") + 'loaded' + """ + return self._colorize(text, self.SUCCESS, bold) + + def warning(self, text: str, bold: bool = False) -> str: + """Format text as warning (yellow). + + Parameters + ---------- + text : str + Text to format. + bold : bool + Whether to apply bold style. Default is False. + + Returns + ------- + str + Formatted text. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.warning("check config") + 'check config' + """ + return self._colorize(text, self.WARNING, bold) + + def error(self, text: str, bold: bool = False) -> str: + """Format text as error (red). + + Parameters + ---------- + text : str + Text to format. + bold : bool + Whether to apply bold style. Default is False. + + Returns + ------- + str + Formatted text. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.error("failed") + 'failed' + """ + return self._colorize(text, self.ERROR, bold) + + def info(self, text: str, bold: bool = False) -> str: + """Format text as info (cyan). + + Parameters + ---------- + text : str + Text to format. + bold : bool + Whether to apply bold style. Default is False. + + Returns + ------- + str + Formatted text. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.info("/path/to/config.yaml") + '/path/to/config.yaml' + """ + return self._colorize(text, self.INFO, bold) + + def highlight(self, text: str, bold: bool = True) -> str: + """Format text as highlighted (magenta, bold by default). + + Parameters + ---------- + text : str + Text to format. + bold : bool + Whether to apply bold style. Default is True. + + Returns + ------- + str + Formatted text. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.highlight("my-session") + 'my-session' + """ + return self._colorize(text, self.HIGHLIGHT, bold) + + def muted(self, text: str) -> str: + """Format text as muted (blue). + + Parameters + ---------- + text : str + Text to format. + + Returns + ------- + str + Formatted text. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.muted("(optional)") + '(optional)' + """ + return self._colorize(text, self.MUTED, bold=False) + + def heading(self, text: str) -> str: + """Format text as a section heading (cyan, bold). + + Used for section headers like 'Local workspaces:' or 'Global workspaces:'. + Distinguished from info() by being bold. + + Parameters + ---------- + text : str + Text to format. + + Returns + ------- + str + Formatted text. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.heading("Local workspaces:") + 'Local workspaces:' + """ + return self._colorize(text, self.INFO, bold=True) + + # Formatting helpers for structured output + + def format_label(self, label: str) -> str: + """Format a label (key in key:value pair). + + Parameters + ---------- + label : str + Label text to format. + + Returns + ------- + str + Highlighted label text (bold magenta when colors enabled). + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.format_label("tmux path") + 'tmux path' + """ + return self.highlight(label, bold=True) + + def format_path(self, path: str) -> str: + """Format a file path with info color. + + Parameters + ---------- + path : str + Path string to format. + + Returns + ------- + str + Cyan-colored path when colors enabled. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.format_path("/usr/bin/tmux") + '/usr/bin/tmux' + """ + return self.info(path) + + def format_version(self, version: str) -> str: + """Format a version string. + + Parameters + ---------- + version : str + Version string to format. + + Returns + ------- + str + Cyan-colored version when colors enabled. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.format_version("3.2a") + '3.2a' + """ + return self.info(version) + + def format_separator(self, length: int = 25) -> str: + """Format a visual separator line. + + Parameters + ---------- + length : int + Length of the separator line. Default is 25. + + Returns + ------- + str + Muted (blue) separator line when colors enabled. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.format_separator() + '-------------------------' + >>> colors.format_separator(10) + '----------' + """ + return self.muted("-" * length) + + def format_kv(self, key: str, value: str) -> str: + """Format key: value pair with syntax highlighting. + + Parameters + ---------- + key : str + Key/label to highlight. + value : str + Value to display (not colorized, allows caller to format). + + Returns + ------- + str + Formatted "key: value" string with highlighted key. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.format_kv("tmux version", "3.2a") + 'tmux version: 3.2a' + """ + return f"{self.format_label(key)}: {value}" + + def format_tmux_option(self, line: str) -> str: + """Format tmux option line with syntax highlighting. + + Handles tmux show-options output formats: + - "key value" (space-separated) + - "key=value" (equals-separated) + - "key[index] value" (array-indexed options) + - "key" (empty array options with no value) + + Parameters + ---------- + line : str + Option line to format. + + Returns + ------- + str + Formatted line with highlighted key and info-colored value. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.format_tmux_option("status on") + 'status on' + >>> colors.format_tmux_option("base-index=1") + 'base-index=1' + >>> colors.format_tmux_option("pane-colours") + 'pane-colours' + >>> colors.format_tmux_option('status-format[0] "#[align=left]"') + 'status-format[0] "#[align=left]"' + """ + # Handle "key value" format (space-separated) - check first since values + # may contain '=' (e.g., status-format[0] "#[align=left]") + parts = line.split(None, 1) + if len(parts) == 2: + return f"{self.highlight(parts[0], bold=False)} {self.info(parts[1])}" + + # Handle key=value format (only for single-token lines) + if "=" in line: + key, val = line.split("=", 1) + return f"{self.highlight(key, bold=False)}={self.info(val)}" + + # Single word = key with no value (empty array option like pane-colours) + if len(parts) == 1 and parts[0]: + return self.highlight(parts[0], bold=False) + + # Empty or unparseable - return as-is + return line + + +def get_color_mode(color_arg: str | None = None) -> ColorMode: + """Convert CLI argument string to ColorMode enum. + + Parameters + ---------- + color_arg : str | None + Color mode argument from CLI (auto, always, never). + None defaults to AUTO. + + Returns + ------- + ColorMode + The determined color mode. Invalid values return AUTO. + + Examples + -------- + >>> get_color_mode(None) + + >>> get_color_mode("always") + + >>> get_color_mode("NEVER") + + >>> get_color_mode("invalid") + + """ + if color_arg is None: + return ColorMode.AUTO + + try: + return ColorMode(color_arg.lower()) + except ValueError: + return ColorMode.AUTO + + +# ANSI styling utilities (originally from click, via utils.py) + +_ansi_re = re.compile(r"\033\[[;?0-9]*[a-zA-Z]") + + +def strip_ansi(value: str) -> str: + r"""Clear ANSI escape codes from a string value. + + Parameters + ---------- + value : str + String potentially containing ANSI escape codes. + + Returns + ------- + str + String with ANSI codes removed. + + Examples + -------- + >>> strip_ansi("\033[32mgreen\033[0m") + 'green' + >>> strip_ansi("plain text") + 'plain text' + """ + return _ansi_re.sub("", value) + + +_ansi_colors = { + "black": 30, + "red": 31, + "green": 32, + "yellow": 33, + "blue": 34, + "magenta": 35, + "cyan": 36, + "white": 37, + "reset": 39, + "bright_black": 90, + "bright_red": 91, + "bright_green": 92, + "bright_yellow": 93, + "bright_blue": 94, + "bright_magenta": 95, + "bright_cyan": 96, + "bright_white": 97, +} +_ansi_reset_all = "\033[0m" + + +def _interpret_color( + color: int | tuple[int, int, int] | str, + offset: int = 0, +) -> str: + """Convert color specification to ANSI escape code number. + + Parameters + ---------- + color : int | tuple[int, int, int] | str + Color as 256-color index, RGB tuple, or color name. + offset : int + Offset for background colors (10 for bg). + + Returns + ------- + str + ANSI escape code parameters. + + Examples + -------- + Color name returns base ANSI code: + + >>> _interpret_color("red") + '31' + + 256-color index returns extended format: + + >>> _interpret_color(196) + '38;5;196' + + RGB tuple returns 24-bit format: + + >>> _interpret_color((255, 128, 0)) + '38;2;255;128;0' + """ + if isinstance(color, int): + return f"{38 + offset};5;{color:d}" + + if isinstance(color, (tuple, list)): + if len(color) != 3: + msg = f"RGB color tuple must have exactly 3 values, got {len(color)}" + raise ValueError(msg) + r, g, b = color + return f"{38 + offset};2;{r:d};{g:d};{b:d}" + + return str(_ansi_colors[color] + offset) + + +class UnknownStyleColor(Exception): + """Raised when encountering an unknown terminal style color. + + Examples + -------- + >>> try: + ... raise UnknownStyleColor("invalid_color") + ... except UnknownStyleColor as e: + ... "invalid_color" in str(e) + True + """ + + def __init__(self, color: CLIColour, *args: object, **kwargs: object) -> None: + return super().__init__(f"Unknown color {color!r}", *args, **kwargs) + + +def style( + text: t.Any, + fg: CLIColour | None = None, + bg: CLIColour | None = None, + bold: bool | None = None, + dim: bool | None = None, + underline: bool | None = None, + overline: bool | None = None, + italic: bool | None = None, + blink: bool | None = None, + reverse: bool | None = None, + strikethrough: bool | None = None, + reset: bool = True, +) -> str: + r"""Apply ANSI styling to text. + + Credit: click. + + Parameters + ---------- + text : Any + Text to style (will be converted to str). + fg : CLIColour | None + Foreground color (name, 256-index, or RGB tuple). + bg : CLIColour | None + Background color. + bold : bool | None + Apply bold style. + dim : bool | None + Apply dim style. + underline : bool | None + Apply underline style. + overline : bool | None + Apply overline style. + italic : bool | None + Apply italic style. + blink : bool | None + Apply blink style. + reverse : bool | None + Apply reverse video style. + strikethrough : bool | None + Apply strikethrough style. + reset : bool + Append reset code at end. Default True. + + Returns + ------- + str + Styled text with ANSI escape codes. + + Examples + -------- + >>> style("hello", fg="green") # doctest: +ELLIPSIS + '\x1b[32m...' + >>> "hello" in style("hello", fg="green") + True + """ + if not isinstance(text, str): + text = str(text) + + bits = [] + + if fg or fg == 0: + try: + bits.append(f"\033[{_interpret_color(fg)}m") + except (KeyError, ValueError): + raise UnknownStyleColor(color=fg) from None + + if bg or bg == 0: + try: + bits.append(f"\033[{_interpret_color(bg, 10)}m") + except (KeyError, ValueError): + raise UnknownStyleColor(color=bg) from None + + if bold: + bits.append("\033[1m") + if dim: + bits.append("\033[2m") + if underline is not None: + bits.append(f"\033[{4 if underline else 24}m") + if overline is not None: + bits.append(f"\033[{53 if overline else 55}m") + if italic is not None: + bits.append(f"\033[{3 if italic else 23}m") + if blink is not None: + bits.append(f"\033[{5 if blink else 25}m") + if reverse is not None: + bits.append(f"\033[{7 if reverse else 27}m") + if strikethrough is not None: + bits.append(f"\033[{9 if strikethrough else 29}m") + bits.append(text) + if reset: + bits.append(_ansi_reset_all) + return "".join(bits) + + +def unstyle(text: str) -> str: + r"""Remove ANSI styling information from a string. + + Usually it's not necessary to use this function as tmuxp_echo function will + automatically remove styling if necessary. + + Credit: click. + + Parameters + ---------- + text : str + Text to remove style information from. + + Returns + ------- + str + Text with ANSI codes removed. + + Examples + -------- + >>> unstyle("\033[32mgreen\033[0m") + 'green' + """ + return strip_ansi(text) + + +def build_description( + intro: str, + example_blocks: t.Sequence[tuple[str | None, t.Sequence[str]]], +) -> str: + r"""Assemble help text with optional example sections. + + Parameters + ---------- + intro : str + The introductory description text. + example_blocks : sequence of (heading, commands) tuples + Each tuple contains an optional heading and a sequence of example commands. + If heading is None, the section is titled "examples:". + + Returns + ------- + str + Formatted description with examples. + + Examples + -------- + >>> from tmuxp._internal.colors import build_description + >>> build_description("My tool.", [(None, ["mytool run"])]) + 'My tool.\n\nexamples:\n mytool run' + + >>> build_description("My tool.", [("sync", ["mytool sync repo"])]) + 'My tool.\n\nsync examples:\n mytool sync repo' + + >>> build_description("", [(None, ["cmd"])]) + 'examples:\n cmd' + """ + import textwrap + + sections: list[str] = [] + intro_text = textwrap.dedent(intro).strip() + if intro_text: + sections.append(intro_text) + + for heading, commands in example_blocks: + if not commands: + continue + title = "examples:" if heading is None else f"{heading} examples:" + lines = [title] + lines.extend(f" {command}" for command in commands) + sections.append("\n".join(lines)) + + return "\n\n".join(sections) diff --git a/src/tmuxp/cli/_colors.py b/src/tmuxp/cli/_colors.py index ece4d4adcd..9932218fb6 100644 --- a/src/tmuxp/cli/_colors.py +++ b/src/tmuxp/cli/_colors.py @@ -1,859 +1,32 @@ -"""Color output utilities for tmuxp CLI. +"""Backward-compatible re-exports from _internal.colors. -This module provides semantic color utilities following patterns from vcspull -and CPython's _colorize module. It includes low-level ANSI styling functions -and high-level semantic color utilities. +This module re-exports color utilities from their new location in _internal.colors +for backward compatibility with existing imports. -Examples --------- -Basic usage with automatic TTY detection (AUTO mode is the default). -In a TTY, colored text is returned; otherwise plain text: - ->>> colors = Colors() - -Force colors on or off: - ->>> colors = Colors(ColorMode.ALWAYS) ->>> colors.success("loaded") # doctest: +ELLIPSIS -'...' - ->>> colors = Colors(ColorMode.NEVER) ->>> colors.success("loaded") -'loaded' - -Environment variables NO_COLOR and FORCE_COLOR are respected. -NO_COLOR takes highest priority (disables even in ALWAYS mode): - ->>> monkeypatch.setenv("NO_COLOR", "1") ->>> colors = Colors(ColorMode.ALWAYS) ->>> colors.success("loaded") -'loaded' - -FORCE_COLOR enables colors in AUTO mode even without TTY: - ->>> import sys ->>> monkeypatch.delenv("NO_COLOR", raising=False) ->>> monkeypatch.setenv("FORCE_COLOR", "1") ->>> monkeypatch.setattr(sys.stdout, "isatty", lambda: False) ->>> colors = Colors(ColorMode.AUTO) ->>> colors.success("loaded") # doctest: +ELLIPSIS -'...' +.. deprecated:: + Import directly from tmuxp._internal.colors instead. """ from __future__ import annotations -import enum -import os -import re -import sys -import typing as t - -if t.TYPE_CHECKING: - from typing import TypeAlias - - CLIColour: TypeAlias = int | tuple[int, int, int] | str - - -class ColorMode(enum.Enum): - """Color output modes for CLI. - - Examples - -------- - >>> ColorMode.AUTO.value - 'auto' - >>> ColorMode.ALWAYS.value - 'always' - >>> ColorMode.NEVER.value - 'never' - """ - - AUTO = "auto" - ALWAYS = "always" - NEVER = "never" - - -class Colors: - r"""Semantic color utilities for CLI output. - - Provides semantic color methods (success, warning, error, etc.) that - conditionally apply ANSI colors based on the color mode and environment. - - Parameters - ---------- - mode : ColorMode - Color mode to use. Default is AUTO which detects TTY. - - Attributes - ---------- - SUCCESS : str - Color name for success messages (green) - WARNING : str - Color name for warning messages (yellow) - ERROR : str - Color name for error messages (red) - INFO : str - Color name for informational messages (cyan) - HIGHLIGHT : str - Color name for highlighted/important text (magenta) - MUTED : str - Color name for subdued/secondary text (blue) - - Examples - -------- - >>> colors = Colors(ColorMode.NEVER) - >>> colors.success("session loaded") - 'session loaded' - >>> colors.error("failed to load") - 'failed to load' - - >>> colors = Colors(ColorMode.ALWAYS) - >>> result = colors.success("ok") - - Check that result contains ANSI escape codes: - - >>> "\033[" in result - True - """ - - # Semantic color names (used with style()) - SUCCESS = "green" # Success, loaded, up-to-date - WARNING = "yellow" # Warnings, changes needed - ERROR = "red" # Errors, failures - INFO = "cyan" # Information, paths - HIGHLIGHT = "magenta" # Important labels, session names - MUTED = "blue" # Subdued info, secondary text - - def __init__(self, mode: ColorMode = ColorMode.AUTO) -> None: - """Initialize color manager. - - Parameters - ---------- - mode : ColorMode - Color mode to use (auto, always, never). Default is AUTO. - - Examples - -------- - >>> colors = Colors() - >>> colors.mode - - - >>> colors = Colors(ColorMode.NEVER) - >>> colors._enabled - False - """ - self.mode = mode - self._enabled = self._should_enable() - - def _should_enable(self) -> bool: - """Determine if color should be enabled. - - Follows CPython-style precedence: - 1. NO_COLOR env var (any value) -> disable - 2. ColorMode.NEVER -> disable - 3. ColorMode.ALWAYS -> enable - 4. FORCE_COLOR env var (any value) -> enable - 5. TTY check -> enable if stdout is a terminal - - Returns - ------- - bool - True if colors should be enabled. - - Examples - -------- - >>> colors = Colors(ColorMode.NEVER) - >>> colors._should_enable() - False - """ - # NO_COLOR takes highest priority (standard convention) - if os.environ.get("NO_COLOR"): - return False - - if self.mode == ColorMode.NEVER: - return False - if self.mode == ColorMode.ALWAYS: - return True - - # AUTO mode: check FORCE_COLOR then TTY - if os.environ.get("FORCE_COLOR"): - return True - - return sys.stdout.isatty() - - def _colorize( - self, text: str, fg: str, bold: bool = False, dim: bool = False - ) -> str: - """Apply color using style() function. - - Parameters - ---------- - text : str - Text to colorize. - fg : str - Foreground color name (e.g., "green", "red"). - bold : bool - Whether to apply bold style. Default is False. - dim : bool - Whether to apply dim/faint style. Default is False. - - Returns - ------- - str - Colorized text if enabled, plain text otherwise. - - Examples - -------- - When colors are enabled, applies ANSI escape codes: - - >>> colors = Colors(ColorMode.ALWAYS) - >>> colors._colorize("test", "green") # doctest: +ELLIPSIS - '...' - - When colors are disabled, returns plain text: - - >>> colors = Colors(ColorMode.NEVER) - >>> colors._colorize("test", "green") - 'test' - """ - if self._enabled: - return style(text, fg=fg, bold=bold, dim=dim) - return text - - def success(self, text: str, bold: bool = False) -> str: - """Format text as success (green). - - Parameters - ---------- - text : str - Text to format. - bold : bool - Whether to apply bold style. Default is False. - - Returns - ------- - str - Formatted text. - - Examples - -------- - >>> colors = Colors(ColorMode.NEVER) - >>> colors.success("loaded") - 'loaded' - """ - return self._colorize(text, self.SUCCESS, bold) - - def warning(self, text: str, bold: bool = False) -> str: - """Format text as warning (yellow). - - Parameters - ---------- - text : str - Text to format. - bold : bool - Whether to apply bold style. Default is False. - - Returns - ------- - str - Formatted text. - - Examples - -------- - >>> colors = Colors(ColorMode.NEVER) - >>> colors.warning("check config") - 'check config' - """ - return self._colorize(text, self.WARNING, bold) - - def error(self, text: str, bold: bool = False) -> str: - """Format text as error (red). - - Parameters - ---------- - text : str - Text to format. - bold : bool - Whether to apply bold style. Default is False. - - Returns - ------- - str - Formatted text. - - Examples - -------- - >>> colors = Colors(ColorMode.NEVER) - >>> colors.error("failed") - 'failed' - """ - return self._colorize(text, self.ERROR, bold) - - def info(self, text: str, bold: bool = False) -> str: - """Format text as info (cyan). - - Parameters - ---------- - text : str - Text to format. - bold : bool - Whether to apply bold style. Default is False. - - Returns - ------- - str - Formatted text. - - Examples - -------- - >>> colors = Colors(ColorMode.NEVER) - >>> colors.info("/path/to/config.yaml") - '/path/to/config.yaml' - """ - return self._colorize(text, self.INFO, bold) - - def highlight(self, text: str, bold: bool = True) -> str: - """Format text as highlighted (magenta, bold by default). - - Parameters - ---------- - text : str - Text to format. - bold : bool - Whether to apply bold style. Default is True. - - Returns - ------- - str - Formatted text. - - Examples - -------- - >>> colors = Colors(ColorMode.NEVER) - >>> colors.highlight("my-session") - 'my-session' - """ - return self._colorize(text, self.HIGHLIGHT, bold) - - def muted(self, text: str) -> str: - """Format text as muted (blue). - - Parameters - ---------- - text : str - Text to format. - - Returns - ------- - str - Formatted text. - - Examples - -------- - >>> colors = Colors(ColorMode.NEVER) - >>> colors.muted("(optional)") - '(optional)' - """ - return self._colorize(text, self.MUTED, bold=False) - - def heading(self, text: str) -> str: - """Format text as a section heading (cyan, bold). - - Used for section headers like 'Local workspaces:' or 'Global workspaces:'. - Distinguished from info() by being bold. - - Parameters - ---------- - text : str - Text to format. - - Returns - ------- - str - Formatted text. - - Examples - -------- - >>> colors = Colors(ColorMode.NEVER) - >>> colors.heading("Local workspaces:") - 'Local workspaces:' - """ - return self._colorize(text, self.INFO, bold=True) - - # Formatting helpers for structured output - - def format_label(self, label: str) -> str: - """Format a label (key in key:value pair). - - Parameters - ---------- - label : str - Label text to format. - - Returns - ------- - str - Highlighted label text (bold magenta when colors enabled). - - Examples - -------- - >>> colors = Colors(ColorMode.NEVER) - >>> colors.format_label("tmux path") - 'tmux path' - """ - return self.highlight(label, bold=True) - - def format_path(self, path: str) -> str: - """Format a file path with info color. - - Parameters - ---------- - path : str - Path string to format. - - Returns - ------- - str - Cyan-colored path when colors enabled. - - Examples - -------- - >>> colors = Colors(ColorMode.NEVER) - >>> colors.format_path("/usr/bin/tmux") - '/usr/bin/tmux' - """ - return self.info(path) - - def format_version(self, version: str) -> str: - """Format a version string. - - Parameters - ---------- - version : str - Version string to format. - - Returns - ------- - str - Cyan-colored version when colors enabled. - - Examples - -------- - >>> colors = Colors(ColorMode.NEVER) - >>> colors.format_version("3.2a") - '3.2a' - """ - return self.info(version) - - def format_separator(self, length: int = 25) -> str: - """Format a visual separator line. - - Parameters - ---------- - length : int - Length of the separator line. Default is 25. - - Returns - ------- - str - Muted (blue) separator line when colors enabled. - - Examples - -------- - >>> colors = Colors(ColorMode.NEVER) - >>> colors.format_separator() - '-------------------------' - >>> colors.format_separator(10) - '----------' - """ - return self.muted("-" * length) - - def format_kv(self, key: str, value: str) -> str: - """Format key: value pair with syntax highlighting. - - Parameters - ---------- - key : str - Key/label to highlight. - value : str - Value to display (not colorized, allows caller to format). - - Returns - ------- - str - Formatted "key: value" string with highlighted key. - - Examples - -------- - >>> colors = Colors(ColorMode.NEVER) - >>> colors.format_kv("tmux version", "3.2a") - 'tmux version: 3.2a' - """ - return f"{self.format_label(key)}: {value}" - - def format_tmux_option(self, line: str) -> str: - """Format tmux option line with syntax highlighting. - - Handles tmux show-options output formats: - - "key value" (space-separated) - - "key=value" (equals-separated) - - "key[index] value" (array-indexed options) - - "key" (empty array options with no value) - - Parameters - ---------- - line : str - Option line to format. - - Returns - ------- - str - Formatted line with highlighted key and info-colored value. - - Examples - -------- - >>> colors = Colors(ColorMode.NEVER) - >>> colors.format_tmux_option("status on") - 'status on' - >>> colors.format_tmux_option("base-index=1") - 'base-index=1' - >>> colors.format_tmux_option("pane-colours") - 'pane-colours' - >>> colors.format_tmux_option('status-format[0] "#[align=left]"') - 'status-format[0] "#[align=left]"' - """ - # Handle "key value" format (space-separated) - check first since values - # may contain '=' (e.g., status-format[0] "#[align=left]") - parts = line.split(None, 1) - if len(parts) == 2: - return f"{self.highlight(parts[0], bold=False)} {self.info(parts[1])}" - - # Handle key=value format (only for single-token lines) - if "=" in line: - key, val = line.split("=", 1) - return f"{self.highlight(key, bold=False)}={self.info(val)}" - - # Single word = key with no value (empty array option like pane-colours) - if len(parts) == 1 and parts[0]: - return self.highlight(parts[0], bold=False) - - # Empty or unparseable - return as-is - return line - - -def get_color_mode(color_arg: str | None = None) -> ColorMode: - """Convert CLI argument string to ColorMode enum. - - Parameters - ---------- - color_arg : str | None - Color mode argument from CLI (auto, always, never). - None defaults to AUTO. - - Returns - ------- - ColorMode - The determined color mode. Invalid values return AUTO. - - Examples - -------- - >>> get_color_mode(None) - - >>> get_color_mode("always") - - >>> get_color_mode("NEVER") - - >>> get_color_mode("invalid") - - """ - if color_arg is None: - return ColorMode.AUTO - - try: - return ColorMode(color_arg.lower()) - except ValueError: - return ColorMode.AUTO - - -# ANSI styling utilities (originally from click, via utils.py) - -_ansi_re = re.compile(r"\033\[[;?0-9]*[a-zA-Z]") - - -def strip_ansi(value: str) -> str: - r"""Clear ANSI escape codes from a string value. - - Parameters - ---------- - value : str - String potentially containing ANSI escape codes. - - Returns - ------- - str - String with ANSI codes removed. - - Examples - -------- - >>> strip_ansi("\033[32mgreen\033[0m") - 'green' - >>> strip_ansi("plain text") - 'plain text' - """ - return _ansi_re.sub("", value) - - -_ansi_colors = { - "black": 30, - "red": 31, - "green": 32, - "yellow": 33, - "blue": 34, - "magenta": 35, - "cyan": 36, - "white": 37, - "reset": 39, - "bright_black": 90, - "bright_red": 91, - "bright_green": 92, - "bright_yellow": 93, - "bright_blue": 94, - "bright_magenta": 95, - "bright_cyan": 96, - "bright_white": 97, -} -_ansi_reset_all = "\033[0m" - - -def _interpret_color( - color: int | tuple[int, int, int] | str, - offset: int = 0, -) -> str: - """Convert color specification to ANSI escape code number. - - Parameters - ---------- - color : int | tuple[int, int, int] | str - Color as 256-color index, RGB tuple, or color name. - offset : int - Offset for background colors (10 for bg). - - Returns - ------- - str - ANSI escape code parameters. - - Examples - -------- - Color name returns base ANSI code: - - >>> _interpret_color("red") - '31' - - 256-color index returns extended format: - - >>> _interpret_color(196) - '38;5;196' - - RGB tuple returns 24-bit format: - - >>> _interpret_color((255, 128, 0)) - '38;2;255;128;0' - """ - if isinstance(color, int): - return f"{38 + offset};5;{color:d}" - - if isinstance(color, (tuple, list)): - if len(color) != 3: - msg = f"RGB color tuple must have exactly 3 values, got {len(color)}" - raise ValueError(msg) - r, g, b = color - return f"{38 + offset};2;{r:d};{g:d};{b:d}" - - return str(_ansi_colors[color] + offset) - - -class UnknownStyleColor(Exception): - """Raised when encountering an unknown terminal style color. - - Examples - -------- - >>> try: - ... raise UnknownStyleColor("invalid_color") - ... except UnknownStyleColor as e: - ... "invalid_color" in str(e) - True - """ - - def __init__(self, color: CLIColour, *args: object, **kwargs: object) -> None: - return super().__init__(f"Unknown color {color!r}", *args, **kwargs) - - -def style( - text: t.Any, - fg: CLIColour | None = None, - bg: CLIColour | None = None, - bold: bool | None = None, - dim: bool | None = None, - underline: bool | None = None, - overline: bool | None = None, - italic: bool | None = None, - blink: bool | None = None, - reverse: bool | None = None, - strikethrough: bool | None = None, - reset: bool = True, -) -> str: - r"""Apply ANSI styling to text. - - Credit: click. - - Parameters - ---------- - text : Any - Text to style (will be converted to str). - fg : CLIColour | None - Foreground color (name, 256-index, or RGB tuple). - bg : CLIColour | None - Background color. - bold : bool | None - Apply bold style. - dim : bool | None - Apply dim style. - underline : bool | None - Apply underline style. - overline : bool | None - Apply overline style. - italic : bool | None - Apply italic style. - blink : bool | None - Apply blink style. - reverse : bool | None - Apply reverse video style. - strikethrough : bool | None - Apply strikethrough style. - reset : bool - Append reset code at end. Default True. - - Returns - ------- - str - Styled text with ANSI escape codes. - - Examples - -------- - >>> style("hello", fg="green") # doctest: +ELLIPSIS - '\x1b[32m...' - >>> "hello" in style("hello", fg="green") - True - """ - if not isinstance(text, str): - text = str(text) - - bits = [] - - if fg or fg == 0: - try: - bits.append(f"\033[{_interpret_color(fg)}m") - except (KeyError, ValueError): - raise UnknownStyleColor(color=fg) from None - - if bg or bg == 0: - try: - bits.append(f"\033[{_interpret_color(bg, 10)}m") - except (KeyError, ValueError): - raise UnknownStyleColor(color=bg) from None - - if bold: - bits.append("\033[1m") - if dim: - bits.append("\033[2m") - if underline is not None: - bits.append(f"\033[{4 if underline else 24}m") - if overline is not None: - bits.append(f"\033[{53 if overline else 55}m") - if italic is not None: - bits.append(f"\033[{3 if italic else 23}m") - if blink is not None: - bits.append(f"\033[{5 if blink else 25}m") - if reverse is not None: - bits.append(f"\033[{7 if reverse else 27}m") - if strikethrough is not None: - bits.append(f"\033[{9 if strikethrough else 29}m") - bits.append(text) - if reset: - bits.append(_ansi_reset_all) - return "".join(bits) - - -def unstyle(text: str) -> str: - r"""Remove ANSI styling information from a string. - - Usually it's not necessary to use this function as tmuxp_echo function will - automatically remove styling if necessary. - - Credit: click. - - Parameters - ---------- - text : str - Text to remove style information from. - - Returns - ------- - str - Text with ANSI codes removed. - - Examples - -------- - >>> unstyle("\033[32mgreen\033[0m") - 'green' - """ - return strip_ansi(text) - - -def build_description( - intro: str, - example_blocks: t.Sequence[tuple[str | None, t.Sequence[str]]], -) -> str: - r"""Assemble help text with optional example sections. - - Parameters - ---------- - intro : str - The introductory description text. - example_blocks : sequence of (heading, commands) tuples - Each tuple contains an optional heading and a sequence of example commands. - If heading is None, the section is titled "examples:". - - Returns - ------- - str - Formatted description with examples. - - Examples - -------- - >>> from tmuxp.cli._colors import build_description - >>> build_description("My tool.", [(None, ["mytool run"])]) - 'My tool.\n\nexamples:\n mytool run' - - >>> build_description("My tool.", [("sync", ["mytool sync repo"])]) - 'My tool.\n\nsync examples:\n mytool sync repo' - - >>> build_description("", [(None, ["cmd"])]) - 'examples:\n cmd' - """ - import textwrap - - sections: list[str] = [] - intro_text = textwrap.dedent(intro).strip() - if intro_text: - sections.append(intro_text) - - for heading, commands in example_blocks: - if not commands: - continue - title = "examples:" if heading is None else f"{heading} examples:" - lines = [title] - lines.extend(f" {command}" for command in commands) - sections.append("\n".join(lines)) - - return "\n\n".join(sections) +from tmuxp._internal.colors import ( + ColorMode, + Colors, + UnknownStyleColor, + build_description, + get_color_mode, + strip_ansi, + style, + unstyle, +) + +__all__ = [ + "ColorMode", + "Colors", + "UnknownStyleColor", + "build_description", + "get_color_mode", + "strip_ansi", + "style", + "unstyle", +] diff --git a/src/tmuxp/cli/utils.py b/src/tmuxp/cli/utils.py index 10aff68b97..7ebc38df82 100644 --- a/src/tmuxp/cli/utils.py +++ b/src/tmuxp/cli/utils.py @@ -6,9 +6,7 @@ import typing as t from tmuxp import log -from tmuxp._internal.private_path import PrivatePath - -from ._colors import ( +from tmuxp._internal.colors import ( ColorMode, Colors, UnknownStyleColor, @@ -16,6 +14,7 @@ style, unstyle, ) +from tmuxp._internal.private_path import PrivatePath if t.TYPE_CHECKING: from collections.abc import Callable, Sequence diff --git a/src/tmuxp/workspace/finders.py b/src/tmuxp/workspace/finders.py index 4166498964..6518870264 100644 --- a/src/tmuxp/workspace/finders.py +++ b/src/tmuxp/workspace/finders.py @@ -7,8 +7,8 @@ import pathlib import typing as t +from tmuxp._internal.colors import ColorMode, Colors from tmuxp._internal.private_path import PrivatePath -from tmuxp.cli._colors import ColorMode, Colors from tmuxp.cli.utils import tmuxp_echo from tmuxp.workspace.constants import VALID_WORKSPACE_DIR_FILE_EXTENSIONS diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py index 63706e2a41..5c32790fbd 100644 --- a/tests/cli/conftest.py +++ b/tests/cli/conftest.py @@ -6,7 +6,7 @@ import pytest -from tmuxp.cli._colors import ColorMode, Colors +from tmuxp._internal.colors import ColorMode, Colors # ANSI escape codes for test assertions # These constants improve test readability by giving semantic names to color codes From f4e1e3840a792eeaba282050aa2431aab8421d7e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 10 Jan 2026 13:08:27 -0600 Subject: [PATCH 78/99] tests/_internal(refactor): Move color tests from cli/ to _internal/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Color tests now belong in _internal/ since the module moved there. what: - Move test_colors.py → tests/_internal/test_colors.py - Move test_colors_formatters.py → tests/_internal/test_colors_formatters.py - Move test_cli_colors_integration.py → tests/_internal/test_colors_integration.py - Create tests/_internal/conftest.py with ANSI constants and color fixtures - Update imports to use tmuxp._internal.colors --- tests/_internal/conftest.py | 31 +++++++++++++++++++ tests/{cli => _internal}/test_colors.py | 6 ++-- .../test_colors_formatters.py | 4 +-- .../test_colors_integration.py} | 6 ++-- 4 files changed, 39 insertions(+), 8 deletions(-) create mode 100644 tests/_internal/conftest.py rename tests/{cli => _internal}/test_colors.py (98%) rename tests/{cli => _internal}/test_colors_formatters.py (98%) rename tests/{cli/test_cli_colors_integration.py => _internal/test_colors_integration.py} (96%) diff --git a/tests/_internal/conftest.py b/tests/_internal/conftest.py new file mode 100644 index 0000000000..927b3f0196 --- /dev/null +++ b/tests/_internal/conftest.py @@ -0,0 +1,31 @@ +"""Shared pytest fixtures for _internal tests.""" + +from __future__ import annotations + +import pytest + +from tmuxp._internal.colors import ColorMode, Colors + +# ANSI escape codes for test assertions +# These constants improve test readability by giving semantic names to color codes +ANSI_GREEN = "\033[32m" +ANSI_RED = "\033[31m" +ANSI_YELLOW = "\033[33m" +ANSI_BLUE = "\033[34m" +ANSI_MAGENTA = "\033[35m" +ANSI_CYAN = "\033[36m" +ANSI_RESET = "\033[0m" +ANSI_BOLD = "\033[1m" + + +@pytest.fixture +def colors_always(monkeypatch: pytest.MonkeyPatch) -> Colors: + """Colors instance with ALWAYS mode and NO_COLOR cleared.""" + monkeypatch.delenv("NO_COLOR", raising=False) + return Colors(ColorMode.ALWAYS) + + +@pytest.fixture +def colors_never() -> Colors: + """Colors instance with colors disabled.""" + return Colors(ColorMode.NEVER) diff --git a/tests/cli/test_colors.py b/tests/_internal/test_colors.py similarity index 98% rename from tests/cli/test_colors.py rename to tests/_internal/test_colors.py index d8d9b47c75..e69bf8408e 100644 --- a/tests/cli/test_colors.py +++ b/tests/_internal/test_colors.py @@ -1,4 +1,4 @@ -"""Tests for CLI color utilities.""" +"""Tests for _internal color utilities.""" from __future__ import annotations @@ -6,7 +6,7 @@ import pytest -from tests.cli.conftest import ( +from tests._internal.conftest import ( ANSI_BLUE, ANSI_BOLD, ANSI_CYAN, @@ -16,7 +16,7 @@ ANSI_RESET, ANSI_YELLOW, ) -from tmuxp.cli._colors import ( +from tmuxp._internal.colors import ( ColorMode, Colors, UnknownStyleColor, diff --git a/tests/cli/test_colors_formatters.py b/tests/_internal/test_colors_formatters.py similarity index 98% rename from tests/cli/test_colors_formatters.py rename to tests/_internal/test_colors_formatters.py index deab92ff16..c7f9d80297 100644 --- a/tests/cli/test_colors_formatters.py +++ b/tests/_internal/test_colors_formatters.py @@ -4,8 +4,8 @@ import pytest -from tests.cli.conftest import ANSI_BLUE, ANSI_BOLD, ANSI_CYAN, ANSI_MAGENTA -from tmuxp.cli._colors import ColorMode, Colors +from tests._internal.conftest import ANSI_BLUE, ANSI_BOLD, ANSI_CYAN, ANSI_MAGENTA +from tmuxp._internal.colors import ColorMode, Colors # format_label tests diff --git a/tests/cli/test_cli_colors_integration.py b/tests/_internal/test_colors_integration.py similarity index 96% rename from tests/cli/test_cli_colors_integration.py rename to tests/_internal/test_colors_integration.py index 28169dcfe1..5927a3b71b 100644 --- a/tests/cli/test_cli_colors_integration.py +++ b/tests/_internal/test_colors_integration.py @@ -1,4 +1,4 @@ -"""Integration tests for CLI color output across all commands.""" +"""Integration tests for color output across all commands.""" from __future__ import annotations @@ -7,8 +7,8 @@ import pytest -from tests.cli.conftest import ANSI_BOLD, ANSI_MAGENTA, ANSI_RESET -from tmuxp.cli._colors import ColorMode, Colors, get_color_mode +from tests._internal.conftest import ANSI_BOLD, ANSI_MAGENTA, ANSI_RESET +from tmuxp._internal.colors import ColorMode, Colors, get_color_mode # Color flag integration tests From bb435f2a4b058aba5ea92c5c5ee95a10c3c615f6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 10 Jan 2026 13:11:52 -0600 Subject: [PATCH 79/99] log(refactor): Move tmuxp_echo to log module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: tmuxp_echo was in cli/utils.py but used by workspace/finders.py, creating a reverse dependency (workspace→cli). Moving it to log.py puts it in the right architectural layer alongside other logging utilities. what: - Add tmuxp_echo function to src/tmuxp/log.py - Re-export from cli/utils.py for backward compatibility - Update workspace/finders.py to import from tmuxp.log - Fix load.py to set log level on tmuxp root logger for --log-file --- src/tmuxp/cli/load.py | 7 +++--- src/tmuxp/cli/utils.py | 22 +---------------- src/tmuxp/log.py | 43 ++++++++++++++++++++++++++++++++++ src/tmuxp/workspace/finders.py | 2 +- 4 files changed, 49 insertions(+), 25 deletions(-) diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index e55943d16f..ec0e233370 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -592,9 +592,10 @@ def command_load( if args.log_file: logfile_handler = logging.FileHandler(args.log_file) logfile_handler.setFormatter(log.LogFormatter()) - from . import logger - - logger.addHandler(logfile_handler) + # 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) 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/utils.py b/src/tmuxp/cli/utils.py index 7ebc38df82..039c949605 100644 --- a/src/tmuxp/cli/utils.py +++ b/src/tmuxp/cli/utils.py @@ -2,10 +2,8 @@ from __future__ import annotations -import logging import typing as t -from tmuxp import log from tmuxp._internal.colors import ( ColorMode, Colors, @@ -15,6 +13,7 @@ unstyle, ) from tmuxp._internal.private_path import PrivatePath +from tmuxp.log import tmuxp_echo if t.TYPE_CHECKING: from collections.abc import Callable, Sequence @@ -34,25 +33,6 @@ "unstyle", ] -logger = logging.getLogger(__name__) - - -def tmuxp_echo( - message: str | None = None, - log_level: str = "INFO", - style_log: bool = False, -) -> None: - """Combine logging.log and click.echo.""" - if message is None: - return - - if style_log: - logger.log(log.LOG_LEVELS[log_level], message) - else: - logger.log(log.LOG_LEVELS[log_level], unstyle(message)) - - print(message) # NOQA: T201 RUF100 - def prompt( name: str, diff --git a/src/tmuxp/log.py b/src/tmuxp/log.py index 6e53ab05b5..e4429eda6a 100644 --- a/src/tmuxp/log.py +++ b/src/tmuxp/log.py @@ -9,6 +9,8 @@ from colorama import Fore, Style +from tmuxp._internal.colors import unstyle + LEVEL_COLORS = { "DEBUG": Fore.BLUE, # Blue "INFO": Fore.GREEN, # Green @@ -200,3 +202,44 @@ class DebugLogFormatter(LogFormatter): """Provides greater technical details than standard log Formatter.""" template = debug_log_template + + +# Use tmuxp root logger so messages propagate to CLI handlers +_echo_logger = logging.getLogger("tmuxp") + + +def tmuxp_echo( + message: str | None = None, + log_level: str = "INFO", + style_log: bool = False, +) -> None: + """Combine logging.log and print for 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. + + Examples + -------- + >>> tmuxp_echo("Session loaded") # doctest: +ELLIPSIS + Session loaded + + >>> tmuxp_echo("Warning message", log_level="WARNING") # doctest: +ELLIPSIS + 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) diff --git a/src/tmuxp/workspace/finders.py b/src/tmuxp/workspace/finders.py index 6518870264..da19bcc887 100644 --- a/src/tmuxp/workspace/finders.py +++ b/src/tmuxp/workspace/finders.py @@ -9,7 +9,7 @@ from tmuxp._internal.colors import ColorMode, Colors from tmuxp._internal.private_path import PrivatePath -from tmuxp.cli.utils import tmuxp_echo +from tmuxp.log import tmuxp_echo from tmuxp.workspace.constants import VALID_WORKSPACE_DIR_FILE_EXTENSIONS logger = logging.getLogger(__name__) From fff98f5dbb862406c6fa59b32761cca91bd6d42f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 10 Jan 2026 13:12:59 -0600 Subject: [PATCH 80/99] cli/utils(fix[prompt_choices]): Fix type hint for choices parameter why: The original type `list[str] | tuple[str, str]` was semantically wrong. `tuple[str, str]` means exactly 2 strings, not a sequence of choice items. what: - Change type from `list[str] | tuple[str, str]` to `Sequence[str | tuple[str, str]]` - This correctly describes: a sequence of items where each is str or (key, value) tuple - Update docstring to clarify parameter semantics --- src/tmuxp/cli/utils.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/tmuxp/cli/utils.py b/src/tmuxp/cli/utils.py index 039c949605..58896deb0f 100644 --- a/src/tmuxp/cli/utils.py +++ b/src/tmuxp/cli/utils.py @@ -163,7 +163,7 @@ def prompt_yes_no( def prompt_choices( name: str, - choices: list[str] | tuple[str, str], + choices: Sequence[str | tuple[str, str]], default: str | None = None, no_choice: Sequence[str] = ("none",), *, @@ -176,8 +176,8 @@ def prompt_choices( name : prompt text choices : - list or tuple of available choices. Choices may be single strings or - (key, value) tuples. + Sequence of available choices. Each choice may be a single string or + a (key, value) tuple where key is used for matching. default : default value if no input provided. no_choice : From 52fa833712d188dad859c327f25d04da082f25cf Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 10 Jan 2026 13:14:21 -0600 Subject: [PATCH 81/99] _internal/colors(fix[heading]): Use bright_cyan for heading() color why: Both heading() and info() used cyan color, violating the rule that adjacent hierarchy levels should have visually distinct colors. Bold alone may not be distinguishable on some terminal/font combinations. what: - Add HEADING constant with value "bright_cyan" - Update heading() to use HEADING instead of INFO - Bright cyan is visually distinct from regular cyan while maintaining the color family cohesion for information-related text --- src/tmuxp/_internal/colors.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/tmuxp/_internal/colors.py b/src/tmuxp/_internal/colors.py index 7ff53c3edb..a9b37a5b61 100644 --- a/src/tmuxp/_internal/colors.py +++ b/src/tmuxp/_internal/colors.py @@ -93,6 +93,8 @@ class Colors: Color name for error messages (red) INFO : str Color name for informational messages (cyan) + HEADING : str + Color name for section headers (bright_cyan) HIGHLIGHT : str Color name for highlighted/important text (magenta) MUTED : str @@ -119,9 +121,10 @@ class Colors: SUCCESS = "green" # Success, loaded, up-to-date WARNING = "yellow" # Warnings, changes needed ERROR = "red" # Errors, failures - INFO = "cyan" # Information, paths - HIGHLIGHT = "magenta" # Important labels, session names - MUTED = "blue" # Subdued info, secondary text + INFO = "cyan" # Information, paths, supplementary (L2) + HEADING = "bright_cyan" # Section headers (L0) - brighter than INFO + HIGHLIGHT = "magenta" # Important labels, session names (L1) + MUTED = "blue" # Subdued info, secondary text (L3) def __init__(self, mode: ColorMode = ColorMode.AUTO) -> None: """Initialize color manager. @@ -356,10 +359,10 @@ def muted(self, text: str) -> str: return self._colorize(text, self.MUTED, bold=False) def heading(self, text: str) -> str: - """Format text as a section heading (cyan, bold). + """Format text as a section heading (bright cyan, bold). Used for section headers like 'Local workspaces:' or 'Global workspaces:'. - Distinguished from info() by being bold. + Uses bright_cyan to visually distinguish from info() which uses cyan. Parameters ---------- @@ -377,7 +380,7 @@ def heading(self, text: str) -> str: >>> colors.heading("Local workspaces:") 'Local workspaces:' """ - return self._colorize(text, self.INFO, bold=True) + return self._colorize(text, self.HEADING, bold=True) # Formatting helpers for structured output From bc882d8665da4734684ff0d297b926a98239ef01 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 10 Jan 2026 14:15:34 -0600 Subject: [PATCH 82/99] Add RGB value range validation to style() Validate that RGB tuple values are: - Integers (raises TypeError -> UnknownStyleColor if not) - In the 0-255 range (raises ValueError -> UnknownStyleColor if not) Previously, invalid values like (256, 0, 0) or (-1, 128, 0) would produce malformed ANSI escape codes that violate the standard. --- src/tmuxp/_internal/colors.py | 14 ++++++++++++-- tests/_internal/test_colors.py | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/tmuxp/_internal/colors.py b/src/tmuxp/_internal/colors.py index a9b37a5b61..296dad619d 100644 --- a/src/tmuxp/_internal/colors.py +++ b/src/tmuxp/_internal/colors.py @@ -670,6 +670,16 @@ def _interpret_color( msg = f"RGB color tuple must have exactly 3 values, got {len(color)}" raise ValueError(msg) r, g, b = color + for i, component in enumerate((r, g, b)): + if not isinstance(component, int): + msg = ( + f"RGB values must be integers, " + f"got {type(component).__name__} at index {i}" + ) + raise TypeError(msg) + if not 0 <= component <= 255: + msg = f"RGB values must be 0-255, got {component} at index {i}" + raise ValueError(msg) return f"{38 + offset};2;{r:d};{g:d};{b:d}" return str(_ansi_colors[color] + offset) @@ -756,13 +766,13 @@ def style( if fg or fg == 0: try: bits.append(f"\033[{_interpret_color(fg)}m") - except (KeyError, ValueError): + except (KeyError, ValueError, TypeError): raise UnknownStyleColor(color=fg) from None if bg or bg == 0: try: bits.append(f"\033[{_interpret_color(bg, 10)}m") - except (KeyError, ValueError): + except (KeyError, ValueError, TypeError): raise UnknownStyleColor(color=bg) from None if bold: diff --git a/tests/_internal/test_colors.py b/tests/_internal/test_colors.py index e69bf8408e..3d6540ca4f 100644 --- a/tests/_internal/test_colors.py +++ b/tests/_internal/test_colors.py @@ -279,3 +279,21 @@ def test_style_with_empty_tuple() -> None: # Empty tuple is falsy, so no fg color is applied assert "test" in result assert "\033[38" not in result # No foreground color escape + + +def test_style_with_rgb_value_too_high() -> None: + """style() should reject RGB values > 255.""" + with pytest.raises(UnknownStyleColor): + style("test", fg=(256, 0, 0)) + + +def test_style_with_rgb_value_negative() -> None: + """style() should reject negative RGB values.""" + with pytest.raises(UnknownStyleColor): + style("test", fg=(-1, 128, 0)) + + +def test_style_with_rgb_non_integer() -> None: + """style() should reject non-integer RGB values.""" + with pytest.raises(UnknownStyleColor): + style("test", fg=(255.5, 128, 0)) # type: ignore[arg-type] From 385b4e997ec6d06aac6f946826c983b5d73daff9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 10 Jan 2026 14:17:08 -0600 Subject: [PATCH 83/99] ls.py: Fix help text jq example The jq example `.[] | .name` doesn't work with the JSON output format `{"workspaces": [...]}`. Fix to `.workspaces[].name`. --- src/tmuxp/cli/ls.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tmuxp/cli/ls.py b/src/tmuxp/cli/ls.py index 517f682500..ecb453a47e 100644 --- a/src/tmuxp/cli/ls.py +++ b/src/tmuxp/cli/ls.py @@ -62,7 +62,7 @@ "tmuxp ls --json", "tmuxp ls --json --full", "tmuxp ls --ndjson", - "tmuxp ls --json | jq '.[] | .name'", + "tmuxp ls --json | jq '.workspaces[].name'", ], ), ), From 11ec9ad564f327abe48e278efdf5e8224dc90338 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 10 Jan 2026 14:26:51 -0600 Subject: [PATCH 84/99] ai(rules[AGENTS]): Update heading() color to bright_cyan --- AGENTS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index dee1d9abe5..c7be997846 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -213,7 +213,7 @@ Inspired by patterns from **jq** (object keys vs values), **ripgrep** (path/line | Level | Element Type | Method | Color | Examples | |-------|--------------|--------|-------|----------| -| **L0** | Section headers | `heading()` | Cyan + bold | "Local workspaces:", "Global workspaces:" | +| **L0** | Section headers | `heading()` | Bright cyan + bold | "Local workspaces:", "Global workspaces:" | | **L1** | Primary content | `highlight()` | Magenta + bold | Workspace names (braintree, .tmuxp) | | **L2** | Supplementary info | `info()` | Cyan | Paths (~/.tmuxp, ~/project/.tmuxp.yaml) | | **L3** | Metadata/labels | `muted()` | Blue | Source labels (Legacy:, XDG default:) | @@ -229,7 +229,7 @@ Inspired by patterns from **jq** (object keys vs values), **ripgrep** (path/line ### Example Output ``` -Local workspaces: ← heading() cyan+bold +Local workspaces: ← heading() bright_cyan+bold .tmuxp ~/work/python/tmuxp/.tmuxp.yaml ← highlight() + info() Global workspaces (~/.tmuxp): ← heading() + info() From 101fe6401b9fbf6cbf39587016d57681fb55382a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 10 Jan 2026 16:09:55 -0600 Subject: [PATCH 85/99] ai(rules[AGENTS]): Update colors module path to canonical location --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index c7be997846..3432304535 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -198,7 +198,7 @@ $ uv run pytest --cov ## CLI Color Semantics (Revision 1, 2026-01-04) -The CLI uses semantic colors via the `Colors` class in `src/tmuxp/cli/_colors.py`. Colors are chosen based on **hierarchy level** and **semantic meaning**, not just data type. +The CLI uses semantic colors via the `Colors` class in `src/tmuxp/_internal/colors.py`. Colors are chosen based on **hierarchy level** and **semantic meaning**, not just data type. ### Design Principles From e7abc7ff097c5067d6441833727a2948659ec5fa Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 10 Jan 2026 16:11:32 -0600 Subject: [PATCH 86/99] docs(api/internals): Add colors and private_path module documentation --- docs/api/internals/colors.md | 14 ++++++++++++++ docs/api/internals/index.md | 2 ++ docs/api/internals/private_path.md | 14 ++++++++++++++ 3 files changed, 30 insertions(+) create mode 100644 docs/api/internals/colors.md create mode 100644 docs/api/internals/private_path.md diff --git a/docs/api/internals/colors.md b/docs/api/internals/colors.md new file mode 100644 index 0000000000..b3577d9bae --- /dev/null +++ b/docs/api/internals/colors.md @@ -0,0 +1,14 @@ +# Colors - `tmuxp._internal.colors` + +:::{warning} +Be careful with these! Internal APIs are **not** covered by version policies. They can break or be removed between minor versions! + +If you need an internal API stabilized please [file an issue](https://github.com/tmux-python/tmuxp/issues). +::: + +```{eval-rst} +.. automodule:: tmuxp._internal.colors + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/api/internals/index.md b/docs/api/internals/index.md index 74b5fa0481..b96fc8657b 100644 --- a/docs/api/internals/index.md +++ b/docs/api/internals/index.md @@ -9,6 +9,8 @@ If you need an internal API stabilized please [file an issue](https://github.com ::: ```{toctree} +colors config_reader +private_path types ``` diff --git a/docs/api/internals/private_path.md b/docs/api/internals/private_path.md new file mode 100644 index 0000000000..d329e15169 --- /dev/null +++ b/docs/api/internals/private_path.md @@ -0,0 +1,14 @@ +# Private path - `tmuxp._internal.private_path` + +:::{warning} +Be careful with these! Internal APIs are **not** covered by version policies. They can break or be removed between minor versions! + +If you need an internal API stabilized please [file an issue](https://github.com/tmux-python/tmuxp/issues). +::: + +```{eval-rst} +.. automodule:: tmuxp._internal.private_path + :members: + :show-inheritance: + :undoc-members: +``` From cb8f05dca0a375b35e58fcd5eb944aca9a9263bd Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 10 Jan 2026 16:11:33 -0600 Subject: [PATCH 87/99] docs(api/cli): Add search command API documentation --- docs/api/cli/index.md | 1 + docs/api/cli/search.md | 8 ++++++++ 2 files changed, 9 insertions(+) create mode 100644 docs/api/cli/search.md diff --git a/docs/api/cli/index.md b/docs/api/cli/index.md index 08f49a2131..9289503905 100644 --- a/docs/api/cli/index.md +++ b/docs/api/cli/index.md @@ -16,6 +16,7 @@ freeze import_config load ls +search shell utils ``` diff --git a/docs/api/cli/search.md b/docs/api/cli/search.md new file mode 100644 index 0000000000..bb9747e9d5 --- /dev/null +++ b/docs/api/cli/search.md @@ -0,0 +1,8 @@ +# tmuxp search - `tmuxp.cli.search` + +```{eval-rst} +.. automodule:: tmuxp.cli.search + :members: + :show-inheritance: + :undoc-members: +``` From a51c6768a0efe6980c9b79834397c0a4aea46be9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 10 Jan 2026 16:11:34 -0600 Subject: [PATCH 88/99] docs(cli): Add search command user documentation --- docs/cli/index.md | 1 + docs/cli/search.md | 15 +++++++++++++++ 2 files changed, 16 insertions(+) create mode 100644 docs/cli/search.md diff --git a/docs/cli/index.md b/docs/cli/index.md index 3205cccc3b..156793a1e1 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -11,6 +11,7 @@ load shell ls +search ``` ```{toctree} diff --git a/docs/cli/search.md b/docs/cli/search.md new file mode 100644 index 0000000000..a31a1c729a --- /dev/null +++ b/docs/cli/search.md @@ -0,0 +1,15 @@ +(cli-search)= + +(search-config)= + +# tmuxp search + +Search workspace files by name, session, path, or content. + +```{eval-rst} +.. argparse:: + :module: tmuxp.cli + :func: create_parser + :prog: tmuxp + :path: search +``` From a0490e6d5c3dec2e3ba18879a92d56af67dc073b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 10 Jan 2026 18:33:35 -0600 Subject: [PATCH 89/99] docs(cli): Remove duplicate descriptions from command pages The argparse directive already outputs the command description from the Python docstring, so having it manually repeated in the .md file creates duplication. Remove the redundant text. --- docs/cli/convert.md | 2 -- docs/cli/debug-info.md | 3 --- docs/cli/ls.md | 2 -- docs/cli/search.md | 2 -- 4 files changed, 9 deletions(-) diff --git a/docs/cli/convert.md b/docs/cli/convert.md index 082f82abd6..3378cf8b39 100644 --- a/docs/cli/convert.md +++ b/docs/cli/convert.md @@ -2,8 +2,6 @@ # tmuxp convert -Convert between YAML and JSON - ```{eval-rst} .. argparse:: :module: tmuxp.cli diff --git a/docs/cli/debug-info.md b/docs/cli/debug-info.md index 5bb4fd4b62..fbe524c6ff 100644 --- a/docs/cli/debug-info.md +++ b/docs/cli/debug-info.md @@ -4,9 +4,6 @@ # tmuxp debug-info -Use to collect all relevant information for submitting an issue to -the project. - ```{eval-rst} .. argparse:: :module: tmuxp.cli diff --git a/docs/cli/ls.md b/docs/cli/ls.md index 4e5ec75fee..a65597db45 100644 --- a/docs/cli/ls.md +++ b/docs/cli/ls.md @@ -4,8 +4,6 @@ # tmuxp ls -List sessions. - ```{eval-rst} .. argparse:: :module: tmuxp.cli diff --git a/docs/cli/search.md b/docs/cli/search.md index a31a1c729a..2446a79de7 100644 --- a/docs/cli/search.md +++ b/docs/cli/search.md @@ -4,8 +4,6 @@ # tmuxp search -Search workspace files by name, session, path, or content. - ```{eval-rst} .. argparse:: :module: tmuxp.cli From e72bcd5f5a6c19d742424baf260a83c120c39097 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 10 Jan 2026 18:34:55 -0600 Subject: [PATCH 90/99] cli: Remove trailing colons from example category headings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the trailing colon from example category names in help text (e.g., "Machine-readable output:" → "Machine-readable output"). Cleaner formatting for sphinx-argparse documentation output. --- src/tmuxp/cli/debug_info.py | 2 +- src/tmuxp/cli/ls.py | 2 +- src/tmuxp/cli/search.py | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/tmuxp/cli/debug_info.py b/src/tmuxp/cli/debug_info.py index 427f30a2da..e4e28eeb24 100644 --- a/src/tmuxp/cli/debug_info.py +++ b/src/tmuxp/cli/debug_info.py @@ -31,7 +31,7 @@ ], ), ( - "Machine-readable output:", + "Machine-readable output", [ "tmuxp debug-info --json", ], diff --git a/src/tmuxp/cli/ls.py b/src/tmuxp/cli/ls.py index ecb453a47e..b4db4645cc 100644 --- a/src/tmuxp/cli/ls.py +++ b/src/tmuxp/cli/ls.py @@ -57,7 +57,7 @@ ], ), ( - "Machine-readable output:", + "Machine-readable output", [ "tmuxp ls --json", "tmuxp ls --json --full", diff --git a/src/tmuxp/cli/search.py b/src/tmuxp/cli/search.py index 6b10bee97d..24e5035bf6 100644 --- a/src/tmuxp/cli/search.py +++ b/src/tmuxp/cli/search.py @@ -1039,7 +1039,7 @@ def output_result(result: WorkspaceSearchResult, show_path: bool) -> None: ], ), ( - "Field-scoped search:", + "Field-scoped search", [ "tmuxp search window:editor", "tmuxp search pane:vim", @@ -1047,7 +1047,7 @@ def output_result(result: WorkspaceSearchResult, show_path: bool) -> None: ], ), ( - "Matching options:", + "Matching options", [ "tmuxp search -i DEV", "tmuxp search -S DevProject", @@ -1056,7 +1056,7 @@ def output_result(result: WorkspaceSearchResult, show_path: bool) -> None: ], ), ( - "Multiple patterns:", + "Multiple patterns", [ "tmuxp search dev production", "tmuxp search --any dev production", @@ -1064,7 +1064,7 @@ def output_result(result: WorkspaceSearchResult, show_path: bool) -> None: ], ), ( - "Machine-readable output:", + "Machine-readable output", [ "tmuxp search --json dev", "tmuxp search --ndjson dev | jq '.name'", From 76ad7a7a194d1c0f486e64ffedbeae7a146a4f63 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 11 Jan 2026 03:32:34 -0600 Subject: [PATCH 91/99] cli: Separate category labels from "examples:" in help output Category headings now use clean format without "examples:" suffix: Before: "Field-scoped search examples:" After: "Field-scoped search:" - build_description() formats headings as "{heading}:" not "{heading} examples:" - Formatter recognizes category headings within examples blocks - Add tests for category heading colorization - Update extract_examples_from_help() to match new format --- src/tmuxp/_internal/colors.py | 5 ++-- src/tmuxp/cli/_formatter.py | 13 +++++++--- tests/cli/test_formatter.py | 42 +++++++++++++++++++++++++++++++++ tests/cli/test_help_examples.py | 21 +++++++++++++---- 4 files changed, 72 insertions(+), 9 deletions(-) diff --git a/src/tmuxp/_internal/colors.py b/src/tmuxp/_internal/colors.py index 296dad619d..3bc936cdaf 100644 --- a/src/tmuxp/_internal/colors.py +++ b/src/tmuxp/_internal/colors.py @@ -836,6 +836,7 @@ def build_description( example_blocks : sequence of (heading, commands) tuples Each tuple contains an optional heading and a sequence of example commands. If heading is None, the section is titled "examples:". + If heading is provided, it becomes the section title (without "examples:"). Returns ------- @@ -849,7 +850,7 @@ def build_description( 'My tool.\n\nexamples:\n mytool run' >>> build_description("My tool.", [("sync", ["mytool sync repo"])]) - 'My tool.\n\nsync examples:\n mytool sync repo' + 'My tool.\n\nsync:\n mytool sync repo' >>> build_description("", [(None, ["cmd"])]) 'examples:\n cmd' @@ -864,7 +865,7 @@ def build_description( for heading, commands in example_blocks: if not commands: continue - title = "examples:" if heading is None else f"{heading} examples:" + title = "examples:" if heading is None else f"{heading}:" lines = [title] lines.extend(f" {command}" for command in commands) sections.append("\n".join(lines)) diff --git a/src/tmuxp/cli/_formatter.py b/src/tmuxp/cli/_formatter.py index 385668de79..9dfceaf29e 100644 --- a/src/tmuxp/cli/_formatter.py +++ b/src/tmuxp/cli/_formatter.py @@ -129,11 +129,18 @@ def _fill_text(self, text: str, width: int, indent: str) -> str: leading = stripped_line[:leading_length] content = stripped_line[leading_length:] content_lower = content.lower() - is_section_heading = ( - content_lower.endswith("examples:") and content_lower != "examples:" + # Recognize example section headings: + # - "examples:" starts the examples block + # - "X examples:" or "X:" are sub-section headings within examples + is_examples_start = content_lower == "examples:" + is_category_in_block = ( + in_examples_block and content.endswith(":") and not content[0].isspace() ) + is_section_heading = ( + content_lower.endswith("examples:") or is_category_in_block + ) and not is_examples_start - if is_section_heading or content_lower == "examples:": + if is_section_heading or is_examples_start: formatted_content = f"{theme.heading}{content}{theme.reset}" in_examples_block = True expect_value = False diff --git a/tests/cli/test_formatter.py b/tests/cli/test_formatter.py index 0670664941..0adce28029 100644 --- a/tests/cli/test_formatter.py +++ b/tests/cli/test_formatter.py @@ -102,6 +102,48 @@ def test_fill_text_without_theme_plain_text(self) -> None: assert "\033[" not in result assert "tmuxp load myproject" in result + def test_fill_text_category_headings_colorized( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Category headings within examples block are colorized.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + formatter_cls = create_themed_formatter(colors) + formatter = formatter_cls("tmuxp") + + # Test category heading without "examples:" suffix + text = "examples:\n tmuxp ls\n\nMachine-readable output:\n tmuxp ls --json" + result = formatter._fill_text(text, 80, "") + + # Both headings should be colorized + assert "\033[" in result + assert "examples:" in result + assert "Machine-readable output:" in result + # Commands should also be colorized + assert "tmuxp" in result + assert "--json" in result + + def test_fill_text_category_heading_only_in_examples_block( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Category headings are only recognized within examples block.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + formatter_cls = create_themed_formatter(colors) + formatter = formatter_cls("tmuxp") + + # Text before examples block should not be colorized as heading + text = "Some heading:\n not a command\n\nexamples:\n tmuxp load" + result = formatter._fill_text(text, 80, "") + + # "Some heading:" should NOT be colorized (it's before examples block) + # "examples:" and the command should be colorized + lines = result.split("\n") + # First line should be plain (no ANSI in "Some heading:") + assert "Some heading:" in lines[0] + class TestHelpOutputIntegration: """Integration tests for help output colorization.""" diff --git a/tests/cli/test_help_examples.py b/tests/cli/test_help_examples.py index 4a7225b578..85e8d2e879 100644 --- a/tests/cli/test_help_examples.py +++ b/tests/cli/test_help_examples.py @@ -3,7 +3,6 @@ from __future__ import annotations import argparse -import re import subprocess import pytest @@ -55,19 +54,33 @@ def extract_examples_from_help(help_text: str) -> list[str]: Examples -------- - >>> text = "load examples:\n tmuxp load myproject\n\npositions:" + >>> text = "load:\n tmuxp load myproject\n\npositions:" >>> extract_examples_from_help(text) ['tmuxp load myproject'] >>> text2 = "examples:\n tmuxp debug-info\n\noptions:" >>> extract_examples_from_help(text2) ['tmuxp debug-info'] + + >>> text3 = "Field-scoped search:\n tmuxp search window:editor" + >>> extract_examples_from_help(text3) + ['tmuxp search window:editor'] """ examples = [] in_examples = False for line in help_text.splitlines(): - # Match "examples:" or "load examples:" etc. - if re.match(r"^(\S+\s+)?examples?:$", line, re.IGNORECASE): + # Match example section headings: + # - "examples:" (default examples section) + # - "load examples:" or "load:" (category headings) + # - "Field-scoped search:" (multi-word category headings) + # Exclude argparse sections like "positional arguments:", "options:" + stripped = line.strip() + is_section_heading = ( + stripped.endswith(":") + and stripped not in ("positional arguments:", "options:") + and not stripped.startswith("-") + ) + if is_section_heading: in_examples = True elif in_examples and line.startswith(" "): cmd = line.strip() From d43aced98bde9096c3796b6d9a3b8c1b696426f3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 11 Jan 2026 03:40:46 -0600 Subject: [PATCH 92/99] tests(cli): Convert class-based tests to functional tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per AGENTS.md guidelines: "Write tests as standalone functions, not classes. Avoid class TestFoo: groupings." Converted 24 test classes across 4 files: - test_formatter.py: 4 classes → 14 functions - test_output.py: 7 classes → 23 functions - test_ls.py: 6 classes → 32 functions - test_search.py: 7 classes → 40 functions (keeping parameterized tests) All 303 CLI tests pass. --- tests/cli/test_formatter.py | 413 +++++----- tests/cli/test_ls.py | 1445 +++++++++++++++++------------------ tests/cli/test_output.py | 413 +++++----- tests/cli/test_search.py | 903 +++++++++++----------- 4 files changed, 1574 insertions(+), 1600 deletions(-) diff --git a/tests/cli/test_formatter.py b/tests/cli/test_formatter.py index 0adce28029..9902eb387d 100644 --- a/tests/cli/test_formatter.py +++ b/tests/cli/test_formatter.py @@ -15,215 +15,204 @@ ) -class TestCreateThemedFormatter: - """Tests for create_themed_formatter factory.""" - - def test_factory_returns_formatter_subclass(self) -> None: - """Factory returns a TmuxpHelpFormatter subclass.""" - formatter_cls = create_themed_formatter() - assert issubclass(formatter_cls, TmuxpHelpFormatter) - - def test_factory_with_colors_enabled( - self, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - """Formatter has theme when colors enabled.""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - formatter_cls = create_themed_formatter(colors) - formatter = formatter_cls("test") - - assert formatter._theme is not None - assert formatter._theme.prog != "" # Has color codes - - def test_factory_with_colors_disabled(self) -> None: - """Formatter has no theme when colors disabled.""" - colors = Colors(ColorMode.NEVER) - formatter_cls = create_themed_formatter(colors) - formatter = formatter_cls("test") - - assert formatter._theme is None - - def test_factory_auto_mode_respects_no_color( - self, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - """Auto mode respects NO_COLOR environment variable.""" - monkeypatch.setenv("NO_COLOR", "1") - formatter_cls = create_themed_formatter() - formatter = formatter_cls("test") - - assert formatter._theme is None - - def test_factory_auto_mode_respects_force_color( - self, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - """Auto mode respects FORCE_COLOR environment variable.""" - monkeypatch.delenv("NO_COLOR", raising=False) - monkeypatch.setenv("FORCE_COLOR", "1") - formatter_cls = create_themed_formatter() - formatter = formatter_cls("test") - - assert formatter._theme is not None - - -class TestTmuxpHelpFormatterColorization: - """Tests for help text colorization.""" - - def test_fill_text_with_theme_colorizes_examples( - self, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - """Examples section is colorized when theme is set.""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - formatter_cls = create_themed_formatter(colors) - formatter = formatter_cls("tmuxp") - - text = "Examples:\n tmuxp load myproject" - result = formatter._fill_text(text, 80, "") - - # Should contain ANSI escape codes - assert "\033[" in result - assert "tmuxp" in result - assert "load" in result - - def test_fill_text_without_theme_plain_text(self) -> None: - """Examples section is plain text when no theme.""" - colors = Colors(ColorMode.NEVER) - formatter_cls = create_themed_formatter(colors) - formatter = formatter_cls("tmuxp") - - text = "Examples:\n tmuxp load myproject" - result = formatter._fill_text(text, 80, "") - - # Should NOT contain ANSI escape codes - assert "\033[" not in result - assert "tmuxp load myproject" in result - - def test_fill_text_category_headings_colorized( - self, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - """Category headings within examples block are colorized.""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - formatter_cls = create_themed_formatter(colors) - formatter = formatter_cls("tmuxp") - - # Test category heading without "examples:" suffix - text = "examples:\n tmuxp ls\n\nMachine-readable output:\n tmuxp ls --json" - result = formatter._fill_text(text, 80, "") - - # Both headings should be colorized - assert "\033[" in result - assert "examples:" in result - assert "Machine-readable output:" in result - # Commands should also be colorized - assert "tmuxp" in result - assert "--json" in result - - def test_fill_text_category_heading_only_in_examples_block( - self, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - """Category headings are only recognized within examples block.""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - formatter_cls = create_themed_formatter(colors) - formatter = formatter_cls("tmuxp") - - # Text before examples block should not be colorized as heading - text = "Some heading:\n not a command\n\nexamples:\n tmuxp load" - result = formatter._fill_text(text, 80, "") - - # "Some heading:" should NOT be colorized (it's before examples block) - # "examples:" and the command should be colorized - lines = result.split("\n") - # First line should be plain (no ANSI in "Some heading:") - assert "Some heading:" in lines[0] - - -class TestHelpOutputIntegration: - """Integration tests for help output colorization.""" - - def test_parser_help_respects_no_color( - self, - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], - ) -> None: - """Parser --help output is plain when NO_COLOR set.""" - monkeypatch.setenv("NO_COLOR", "1") - monkeypatch.setenv("COLUMNS", "100") - - formatter_cls = create_themed_formatter() - parser = argparse.ArgumentParser( - prog="test", - description="Examples:\n test command", - formatter_class=formatter_cls, - ) - - with pytest.raises(SystemExit): - parser.parse_args(["--help"]) - - captured = capsys.readouterr() - assert "\033[" not in captured.out - - def test_parser_help_colorized_with_force_color( - self, - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], - ) -> None: - """Parser --help output is colorized when FORCE_COLOR set.""" - monkeypatch.delenv("NO_COLOR", raising=False) - monkeypatch.setenv("FORCE_COLOR", "1") - monkeypatch.setenv("COLUMNS", "100") - - formatter_cls = create_themed_formatter() - parser = argparse.ArgumentParser( - prog="test", - description="Examples:\n test command", - formatter_class=formatter_cls, - ) - - with pytest.raises(SystemExit): - parser.parse_args(["--help"]) - - captured = capsys.readouterr() - assert "\033[" in captured.out - - -class TestHelpTheme: - """Tests for HelpTheme creation.""" - - def test_from_colors_with_none_returns_empty_theme(self) -> None: - """HelpTheme.from_colors(None) returns empty theme.""" - theme = HelpTheme.from_colors(None) - - assert theme.prog == "" - assert theme.action == "" - assert theme.reset == "" - - def test_from_colors_disabled_returns_empty_theme(self) -> None: - """HelpTheme.from_colors with disabled colors returns empty theme.""" - colors = Colors(ColorMode.NEVER) - theme = HelpTheme.from_colors(colors) - - assert theme.prog == "" - assert theme.action == "" - assert theme.reset == "" - - def test_from_colors_enabled_returns_colored_theme( - self, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - """HelpTheme.from_colors with enabled colors returns colored theme.""" - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) - theme = HelpTheme.from_colors(colors) - - # Should have ANSI codes - assert "\033[" in theme.prog - assert "\033[" in theme.action - assert theme.reset == ANSI_RESET +def test_create_themed_formatter_returns_subclass() -> None: + """Factory returns a TmuxpHelpFormatter subclass.""" + formatter_cls = create_themed_formatter() + assert issubclass(formatter_cls, TmuxpHelpFormatter) + + +def test_create_themed_formatter_with_colors_enabled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Formatter has theme when colors enabled.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + formatter_cls = create_themed_formatter(colors) + formatter = formatter_cls("test") + + assert formatter._theme is not None + assert formatter._theme.prog != "" # Has color codes + + +def test_create_themed_formatter_with_colors_disabled() -> None: + """Formatter has no theme when colors disabled.""" + colors = Colors(ColorMode.NEVER) + formatter_cls = create_themed_formatter(colors) + formatter = formatter_cls("test") + + assert formatter._theme is None + + +def test_create_themed_formatter_auto_mode_respects_no_color( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Auto mode respects NO_COLOR environment variable.""" + monkeypatch.setenv("NO_COLOR", "1") + formatter_cls = create_themed_formatter() + formatter = formatter_cls("test") + + assert formatter._theme is None + + +def test_create_themed_formatter_auto_mode_respects_force_color( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Auto mode respects FORCE_COLOR environment variable.""" + monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.setenv("FORCE_COLOR", "1") + formatter_cls = create_themed_formatter() + formatter = formatter_cls("test") + + assert formatter._theme is not None + + +def test_fill_text_with_theme_colorizes_examples( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Examples section is colorized when theme is set.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + formatter_cls = create_themed_formatter(colors) + formatter = formatter_cls("tmuxp") + + text = "Examples:\n tmuxp load myproject" + result = formatter._fill_text(text, 80, "") + + # Should contain ANSI escape codes + assert "\033[" in result + assert "tmuxp" in result + assert "load" in result + + +def test_fill_text_without_theme_plain_text() -> None: + """Examples section is plain text when no theme.""" + colors = Colors(ColorMode.NEVER) + formatter_cls = create_themed_formatter(colors) + formatter = formatter_cls("tmuxp") + + text = "Examples:\n tmuxp load myproject" + result = formatter._fill_text(text, 80, "") + + # Should NOT contain ANSI escape codes + assert "\033[" not in result + assert "tmuxp load myproject" in result + + +def test_fill_text_category_headings_colorized( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Category headings within examples block are colorized.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + formatter_cls = create_themed_formatter(colors) + formatter = formatter_cls("tmuxp") + + # Test category heading without "examples:" suffix + text = "examples:\n tmuxp ls\n\nMachine-readable output:\n tmuxp ls --json" + result = formatter._fill_text(text, 80, "") + + # Both headings should be colorized + assert "\033[" in result + assert "examples:" in result + assert "Machine-readable output:" in result + # Commands should also be colorized + assert "tmuxp" in result + assert "--json" in result + + +def test_fill_text_category_heading_only_in_examples_block( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Category headings are only recognized within examples block.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + formatter_cls = create_themed_formatter(colors) + formatter = formatter_cls("tmuxp") + + # Text before examples block should not be colorized as heading + text = "Some heading:\n not a command\n\nexamples:\n tmuxp load" + result = formatter._fill_text(text, 80, "") + + # "Some heading:" should NOT be colorized (it's before examples block) + # "examples:" and the command should be colorized + lines = result.split("\n") + # First line should be plain (no ANSI in "Some heading:") + assert "Some heading:" in lines[0] + + +def test_parser_help_respects_no_color( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Parser --help output is plain when NO_COLOR set.""" + monkeypatch.setenv("NO_COLOR", "1") + monkeypatch.setenv("COLUMNS", "100") + + formatter_cls = create_themed_formatter() + parser = argparse.ArgumentParser( + prog="test", + description="Examples:\n test command", + formatter_class=formatter_cls, + ) + + with pytest.raises(SystemExit): + parser.parse_args(["--help"]) + + captured = capsys.readouterr() + assert "\033[" not in captured.out + + +def test_parser_help_colorized_with_force_color( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Parser --help output is colorized when FORCE_COLOR set.""" + monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.setenv("FORCE_COLOR", "1") + monkeypatch.setenv("COLUMNS", "100") + + formatter_cls = create_themed_formatter() + parser = argparse.ArgumentParser( + prog="test", + description="Examples:\n test command", + formatter_class=formatter_cls, + ) + + with pytest.raises(SystemExit): + parser.parse_args(["--help"]) + + captured = capsys.readouterr() + assert "\033[" in captured.out + + +def test_help_theme_from_colors_with_none_returns_empty() -> None: + """HelpTheme.from_colors(None) returns empty theme.""" + theme = HelpTheme.from_colors(None) + + assert theme.prog == "" + assert theme.action == "" + assert theme.reset == "" + + +def test_help_theme_from_colors_disabled_returns_empty() -> None: + """HelpTheme.from_colors with disabled colors returns empty theme.""" + colors = Colors(ColorMode.NEVER) + theme = HelpTheme.from_colors(colors) + + assert theme.prog == "" + assert theme.action == "" + assert theme.reset == "" + + +def test_help_theme_from_colors_enabled_returns_colored( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """HelpTheme.from_colors with enabled colors returns colored theme.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + theme = HelpTheme.from_colors(colors) + + # Should have ANSI codes + assert "\033[" in theme.prog + assert "\033[" in theme.action + assert theme.reset == ANSI_RESET diff --git a/tests/cli/test_ls.py b/tests/cli/test_ls.py index d0dd0cc8f8..40e1526839 100644 --- a/tests/cli/test_ls.py +++ b/tests/cli/test_ls.py @@ -15,755 +15,730 @@ ) -class TestWorkspaceInfo: - """Tests for workspace info extraction.""" +def test_get_workspace_info_yaml(tmp_path: pathlib.Path) -> None: + """Extract metadata from YAML workspace file.""" + workspace = tmp_path / "test.yaml" + workspace.write_text("session_name: my-session\nwindows: []") - def test_get_workspace_info_yaml(self, tmp_path: pathlib.Path) -> None: - """Extract metadata from YAML workspace file.""" - workspace = tmp_path / "test.yaml" - workspace.write_text("session_name: my-session\nwindows: []") + info = _get_workspace_info(workspace) - info = _get_workspace_info(workspace) + assert info["name"] == "test" + assert info["format"] == "yaml" + assert info["session_name"] == "my-session" + assert info["size"] > 0 + assert "T" in info["mtime"] # ISO format contains T + assert info["source"] == "global" # Default source - assert info["name"] == "test" - assert info["format"] == "yaml" - assert info["session_name"] == "my-session" - assert info["size"] > 0 - assert "T" in info["mtime"] # ISO format contains T - assert info["source"] == "global" # Default source - def test_get_workspace_info_source_local(self, tmp_path: pathlib.Path) -> None: - """Extract metadata with source=local.""" - workspace = tmp_path / ".tmuxp.yaml" - workspace.write_text("session_name: local-session\nwindows: []") +def test_get_workspace_info_source_local(tmp_path: pathlib.Path) -> None: + """Extract metadata with source=local.""" + workspace = tmp_path / ".tmuxp.yaml" + workspace.write_text("session_name: local-session\nwindows: []") - info = _get_workspace_info(workspace, source="local") + info = _get_workspace_info(workspace, source="local") - assert info["name"] == ".tmuxp" - assert info["source"] == "local" - assert info["session_name"] == "local-session" + assert info["name"] == ".tmuxp" + assert info["source"] == "local" + assert info["session_name"] == "local-session" - def test_get_workspace_info_json(self, tmp_path: pathlib.Path) -> None: - """Extract metadata from JSON workspace file.""" - workspace = tmp_path / "test.json" - workspace.write_text('{"session_name": "json-session", "windows": []}') - info = _get_workspace_info(workspace) +def test_get_workspace_info_json(tmp_path: pathlib.Path) -> None: + """Extract metadata from JSON workspace file.""" + workspace = tmp_path / "test.json" + workspace.write_text('{"session_name": "json-session", "windows": []}') - assert info["name"] == "test" - assert info["format"] == "json" - assert info["session_name"] == "json-session" + info = _get_workspace_info(workspace) - def test_get_workspace_info_no_session_name(self, tmp_path: pathlib.Path) -> None: - """Handle workspace without session_name.""" - workspace = tmp_path / "test.yaml" - workspace.write_text("windows: []") - - info = _get_workspace_info(workspace) + assert info["name"] == "test" + assert info["format"] == "json" + assert info["session_name"] == "json-session" - assert info["name"] == "test" - assert info["session_name"] is None - def test_get_workspace_info_invalid_yaml(self, tmp_path: pathlib.Path) -> None: - """Handle invalid YAML gracefully.""" - workspace = tmp_path / "test.yaml" - workspace.write_text("{{{{invalid yaml") +def test_get_workspace_info_no_session_name(tmp_path: pathlib.Path) -> None: + """Handle workspace without session_name.""" + workspace = tmp_path / "test.yaml" + workspace.write_text("windows: []") - info = _get_workspace_info(workspace) - - assert info["name"] == "test" - assert info["session_name"] is None # Couldn't parse, so None + info = _get_workspace_info(workspace) + assert info["name"] == "test" + assert info["session_name"] is None -class TestLsSubparser: - """Tests for ls subparser configuration.""" - def test_create_ls_subparser_adds_tree_flag(self) -> None: - """Verify --tree argument is added.""" - import argparse +def test_get_workspace_info_invalid_yaml(tmp_path: pathlib.Path) -> None: + """Handle invalid YAML gracefully.""" + workspace = tmp_path / "test.yaml" + workspace.write_text("{{{{invalid yaml") - parser = argparse.ArgumentParser() - create_ls_subparser(parser) - args = parser.parse_args(["--tree"]) - - assert args.tree is True - - def test_create_ls_subparser_adds_json_flag(self) -> None: - """Verify --json argument is added.""" - import argparse - - parser = argparse.ArgumentParser() - create_ls_subparser(parser) - args = parser.parse_args(["--json"]) - - assert args.output_json is True - - def test_create_ls_subparser_adds_ndjson_flag(self) -> None: - """Verify --ndjson argument is added.""" - import argparse - - parser = argparse.ArgumentParser() - create_ls_subparser(parser) - args = parser.parse_args(["--ndjson"]) - - assert args.output_ndjson is True - - -class TestLsCli: - """CLI integration tests for tmuxp ls.""" - - def test_ls_cli( - self, - isolated_home: pathlib.Path, - capsys: pytest.CaptureFixture[str], - ) -> None: - """CLI test for tmuxp ls.""" - filenames = [ - ".git/", - ".gitignore/", - "session_1.yaml", - "session_2.yaml", - "session_3.json", - "session_4.txt", - ] - - # should ignore: - # - directories should be ignored - # - extensions not covered in VALID_WORKSPACE_DIR_FILE_EXTENSIONS - ignored_filenames = [".git/", ".gitignore/", "session_4.txt"] - stems = [pathlib.Path(f).stem for f in filenames if f not in ignored_filenames] - - for filename in filenames: - location = isolated_home / f".tmuxp/{filename}" - if filename.endswith("/"): - location.mkdir(parents=True) - else: - location.touch() - - with contextlib.suppress(SystemExit): - cli.cli(["--color=never", "ls"]) - - cli_output = capsys.readouterr().out - - # Output now has headers with directory path, check for workspace names - assert "Global workspaces (~/.tmuxp):" in cli_output - for stem in stems: - assert stem in cli_output - - def test_ls_json_output( - self, - isolated_home: pathlib.Path, - capsys: pytest.CaptureFixture[str], - ) -> None: - """CLI test for tmuxp ls --json.""" - tmuxp_dir = isolated_home / ".tmuxp" - tmuxp_dir.mkdir(parents=True) - (tmuxp_dir / "dev.yaml").write_text("session_name: development\nwindows: []") - (tmuxp_dir / "prod.json").write_text('{"session_name": "production"}') - - with contextlib.suppress(SystemExit): - cli.cli(["ls", "--json"]) - - output = capsys.readouterr().out - data = json.loads(output) - - # JSON output is now an object with workspaces and global_workspace_dirs - assert isinstance(data, dict) - assert "workspaces" in data - assert "global_workspace_dirs" in data - - workspaces = data["workspaces"] - assert len(workspaces) == 2 - - names = {item["name"] for item in workspaces} - assert names == {"dev", "prod"} - - # Verify all expected fields are present - for item in workspaces: - assert "name" in item - assert "path" in item - assert "format" in item - assert "size" in item - assert "mtime" in item - assert "session_name" in item - assert "source" in item - assert item["source"] == "global" - - def test_ls_ndjson_output( - self, - isolated_home: pathlib.Path, - capsys: pytest.CaptureFixture[str], - ) -> None: - """CLI test for tmuxp ls --ndjson.""" - tmuxp_dir = isolated_home / ".tmuxp" - tmuxp_dir.mkdir(parents=True) - (tmuxp_dir / "ws1.yaml").write_text("session_name: s1\nwindows: []") - (tmuxp_dir / "ws2.yaml").write_text("session_name: s2\nwindows: []") - - with contextlib.suppress(SystemExit): - cli.cli(["ls", "--ndjson"]) - - output = capsys.readouterr().out - lines = [line for line in output.strip().split("\n") if line] - - assert len(lines) == 2 - - # Each line should be valid JSON - for line in lines: - data = json.loads(line) - assert "name" in data - assert "session_name" in data - assert "source" in data - - def test_ls_tree_output( - self, - isolated_home: pathlib.Path, - capsys: pytest.CaptureFixture[str], - ) -> None: - """CLI test for tmuxp ls --tree.""" - tmuxp_dir = isolated_home / ".tmuxp" - tmuxp_dir.mkdir(parents=True) - (tmuxp_dir / "dev.yaml").write_text("session_name: development\nwindows: []") - - with contextlib.suppress(SystemExit): - cli.cli(["--color=never", "ls", "--tree"]) - - output = capsys.readouterr().out - - # Tree mode shows directory header - assert "~/.tmuxp" in output - # And indented workspace name - assert "dev" in output - - def test_ls_empty_directory( - self, - isolated_home: pathlib.Path, - capsys: pytest.CaptureFixture[str], - ) -> None: - """CLI test for tmuxp ls with no workspaces.""" - tmuxp_dir = isolated_home / ".tmuxp" - tmuxp_dir.mkdir(parents=True) - - with contextlib.suppress(SystemExit): - cli.cli(["--color=never", "ls"]) - - output = capsys.readouterr().out - assert "No workspaces found" in output - - def test_ls_tree_shows_session_name_if_different( - self, - isolated_home: pathlib.Path, - capsys: pytest.CaptureFixture[str], - ) -> None: - """Tree mode shows session_name if it differs from file name.""" - tmuxp_dir = isolated_home / ".tmuxp" - tmuxp_dir.mkdir(parents=True) - # File named "myfile" but session is "actual-session" - (tmuxp_dir / "myfile.yaml").write_text( - "session_name: actual-session\nwindows: []" - ) - - with contextlib.suppress(SystemExit): - cli.cli(["--color=never", "ls", "--tree"]) - - output = capsys.readouterr().out - - assert "myfile" in output - assert "actual-session" in output - - -class TestLsLocalWorkspaces: - """Tests for local workspace discovery in ls command.""" - - def test_ls_finds_local_workspace_in_cwd( - self, - monkeypatch: pytest.MonkeyPatch, - tmp_path: pathlib.Path, - capsys: pytest.CaptureFixture[str], - ) -> None: - """Ls should find .tmuxp.yaml in current directory.""" - home = tmp_path / "home" - project = home / "project" - project.mkdir(parents=True) - - monkeypatch.setenv("HOME", str(home)) - monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) - monkeypatch.chdir(project) - monkeypatch.setattr(pathlib.Path, "home", lambda: home) - - (project / ".tmuxp.yaml").write_text("session_name: local\nwindows: []") - - with contextlib.suppress(SystemExit): - cli.cli(["--color=never", "ls"]) - - output = capsys.readouterr().out - assert "Local workspaces:" in output - assert ".tmuxp" in output - - def test_ls_finds_local_workspace_in_parent( - self, - monkeypatch: pytest.MonkeyPatch, - tmp_path: pathlib.Path, - capsys: pytest.CaptureFixture[str], - ) -> None: - """Ls should find .tmuxp.yaml in parent directory.""" - home = tmp_path / "home" - project = home / "project" - subdir = project / "src" / "module" - subdir.mkdir(parents=True) - - monkeypatch.setenv("HOME", str(home)) - monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) - monkeypatch.chdir(subdir) - monkeypatch.setattr(pathlib.Path, "home", lambda: home) - - (project / ".tmuxp.yaml").write_text("session_name: parent-local\nwindows: []") - - with contextlib.suppress(SystemExit): - cli.cli(["--color=never", "ls"]) - - output = capsys.readouterr().out - assert "Local workspaces:" in output - assert ".tmuxp" in output - - def test_ls_shows_local_and_global( - self, - monkeypatch: pytest.MonkeyPatch, - tmp_path: pathlib.Path, - capsys: pytest.CaptureFixture[str], - ) -> None: - """Ls should show both local and global workspaces.""" - home = tmp_path / "home" - project = home / "project" - project.mkdir(parents=True) - tmuxp_dir = home / ".tmuxp" - tmuxp_dir.mkdir(parents=True) - - monkeypatch.setenv("HOME", str(home)) - monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) - monkeypatch.chdir(project) - monkeypatch.setattr(pathlib.Path, "home", lambda: home) - - # Local workspace - (project / ".tmuxp.yaml").write_text("session_name: local\nwindows: []") - # Global workspace - (tmuxp_dir / "global.yaml").write_text("session_name: global\nwindows: []") - - with contextlib.suppress(SystemExit): - cli.cli(["--color=never", "ls"]) - - output = capsys.readouterr().out - assert "Local workspaces:" in output - assert "Global workspaces (~/.tmuxp):" in output - assert ".tmuxp" in output - assert "global" in output - - def test_ls_json_includes_source_for_local( - self, - monkeypatch: pytest.MonkeyPatch, - tmp_path: pathlib.Path, - capsys: pytest.CaptureFixture[str], - ) -> None: - """JSON output should include source=local for local workspaces.""" - home = tmp_path / "home" - project = home / "project" - project.mkdir(parents=True) - tmuxp_dir = home / ".tmuxp" - tmuxp_dir.mkdir(parents=True) - - monkeypatch.setenv("HOME", str(home)) - monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) - monkeypatch.chdir(project) - monkeypatch.setattr(pathlib.Path, "home", lambda: home) - - (project / ".tmuxp.yaml").write_text("session_name: local\nwindows: []") - (tmuxp_dir / "global.yaml").write_text("session_name: global\nwindows: []") - - with contextlib.suppress(SystemExit): - cli.cli(["ls", "--json"]) - - output = capsys.readouterr().out - data = json.loads(output) - - # JSON output is now an object with workspaces and global_workspace_dirs - assert isinstance(data, dict) - workspaces = data["workspaces"] - - sources = {item["source"] for item in workspaces} - assert sources == {"local", "global"} - - local_items = [item for item in workspaces if item["source"] == "local"] - global_items = [item for item in workspaces if item["source"] == "global"] - - assert len(local_items) == 1 - assert len(global_items) == 1 - assert local_items[0]["session_name"] == "local" - assert global_items[0]["session_name"] == "global" - - def test_ls_local_shows_path( - self, - monkeypatch: pytest.MonkeyPatch, - tmp_path: pathlib.Path, - capsys: pytest.CaptureFixture[str], - ) -> None: - """Local workspaces should show their path in flat mode.""" - home = tmp_path / "home" - project = home / "project" - project.mkdir(parents=True) - - monkeypatch.setenv("HOME", str(home)) - monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) - monkeypatch.chdir(project) - monkeypatch.setattr(pathlib.Path, "home", lambda: home) - - (project / ".tmuxp.yaml").write_text("session_name: local\nwindows: []") - - with contextlib.suppress(SystemExit): - cli.cli(["--color=never", "ls"]) - - output = capsys.readouterr().out - # Local workspace output shows path (with ~ contraction) - assert "~/project/.tmuxp.yaml" in output - - -class TestLsFullFlag: - """Tests for --full flag in ls command.""" - - def test_ls_full_flag_subparser(self) -> None: - """Verify --full argument is added to subparser.""" - import argparse - - from tmuxp.cli.ls import create_ls_subparser - - parser = argparse.ArgumentParser() - create_ls_subparser(parser) - args = parser.parse_args(["--full"]) - - assert args.full is True - - def test_get_workspace_info_include_config( - self, - tmp_path: pathlib.Path, - ) -> None: - """Test _get_workspace_info with include_config=True.""" - workspace = tmp_path / "test.yaml" - workspace.write_text("session_name: test\nwindows:\n - window_name: editor\n") - - info = _get_workspace_info(workspace, include_config=True) - - assert "config" in info - assert info["config"]["session_name"] == "test" - assert len(info["config"]["windows"]) == 1 - - def test_get_workspace_info_no_config_by_default( - self, - tmp_path: pathlib.Path, - ) -> None: - """Test _get_workspace_info without include_config doesn't include config.""" - workspace = tmp_path / "test.yaml" - workspace.write_text("session_name: test\nwindows: []\n") - - info = _get_workspace_info(workspace) - - assert "config" not in info - - def test_ls_json_full_includes_config( - self, - isolated_home: pathlib.Path, - capsys: pytest.CaptureFixture[str], - ) -> None: - """JSON output with --full includes config content.""" - tmuxp_dir = isolated_home / ".tmuxp" - tmuxp_dir.mkdir(parents=True) - (tmuxp_dir / "dev.yaml").write_text( - "session_name: dev\n" - "windows:\n" - " - window_name: editor\n" - " panes:\n" - " - vim\n" - ) - - with contextlib.suppress(SystemExit): - cli.cli(["ls", "--json", "--full"]) - - output = capsys.readouterr().out - data = json.loads(output) - - # JSON output is now an object with workspaces and global_workspace_dirs - assert isinstance(data, dict) - workspaces = data["workspaces"] - - assert len(workspaces) == 1 - assert "config" in workspaces[0] - assert workspaces[0]["config"]["session_name"] == "dev" - assert workspaces[0]["config"]["windows"][0]["window_name"] == "editor" - - def test_ls_full_tree_shows_windows( - self, - isolated_home: pathlib.Path, - capsys: pytest.CaptureFixture[str], - ) -> None: - """Tree mode with --full shows window/pane hierarchy.""" - tmuxp_dir = isolated_home / ".tmuxp" - tmuxp_dir.mkdir(parents=True) - (tmuxp_dir / "dev.yaml").write_text( - "session_name: dev\n" - "windows:\n" - " - window_name: editor\n" - " layout: main-horizontal\n" - " panes:\n" - " - vim\n" - " - window_name: shell\n" - ) - - with contextlib.suppress(SystemExit): - cli.cli(["--color=never", "ls", "--tree", "--full"]) - - output = capsys.readouterr().out - - assert "dev" in output - assert "editor" in output - assert "main-horizontal" in output - assert "shell" in output - assert "pane 0" in output - - def test_ls_full_flat_shows_windows( - self, - isolated_home: pathlib.Path, - capsys: pytest.CaptureFixture[str], - ) -> None: - """Flat mode with --full shows window/pane hierarchy.""" - tmuxp_dir = isolated_home / ".tmuxp" - tmuxp_dir.mkdir(parents=True) - (tmuxp_dir / "dev.yaml").write_text( - "session_name: dev\n" - "windows:\n" - " - window_name: code\n" - " panes:\n" - " - nvim\n" - ) - - with contextlib.suppress(SystemExit): - cli.cli(["--color=never", "ls", "--full"]) - - output = capsys.readouterr().out - - assert "Global workspaces (~/.tmuxp):" in output - assert "dev" in output - assert "code" in output - assert "pane 0" in output - - def test_ls_full_without_json_no_config_in_output( - self, - isolated_home: pathlib.Path, - capsys: pytest.CaptureFixture[str], - ) -> None: - """Non-JSON with --full shows tree but not raw config.""" - tmuxp_dir = isolated_home / ".tmuxp" - tmuxp_dir.mkdir(parents=True) - (tmuxp_dir / "dev.yaml").write_text( - "session_name: dev\nwindows:\n - window_name: editor\n" - ) - - with contextlib.suppress(SystemExit): - cli.cli(["--color=never", "ls", "--full"]) - - output = capsys.readouterr().out - - # Should show tree structure, not raw config keys - assert "editor" in output - assert "session_name:" not in output # Raw YAML not in output - - -class TestLsGlobalWorkspaceDirs: - """Tests for global workspace directories display in ls command.""" - - def test_ls_shows_global_workspace_dirs_section( - self, - monkeypatch: pytest.MonkeyPatch, - tmp_path: pathlib.Path, - capsys: pytest.CaptureFixture[str], - ) -> None: - """Human output shows global workspace directories section.""" - home = tmp_path / "home" - tmuxp_dir = home / ".tmuxp" - tmuxp_dir.mkdir(parents=True) - - monkeypatch.setenv("HOME", str(home)) - monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) - monkeypatch.chdir(home) - monkeypatch.setattr(pathlib.Path, "home", lambda: home) - monkeypatch.delenv("TMUXP_CONFIGDIR", raising=False) - - (tmuxp_dir / "workspace.yaml").write_text("session_name: test\nwindows: []") - - with contextlib.suppress(SystemExit): - cli.cli(["--color=never", "ls"]) - - output = capsys.readouterr().out - - assert "Global workspace directories:" in output - assert "Legacy: ~/.tmuxp" in output - assert "1 workspace" in output - assert "active" in output - assert "~/.config/tmuxp" in output - assert "not found" in output - - def test_ls_global_header_shows_active_dir( - self, - monkeypatch: pytest.MonkeyPatch, - tmp_path: pathlib.Path, - capsys: pytest.CaptureFixture[str], - ) -> None: - """Global workspaces header shows active directory path.""" - home = tmp_path / "home" - tmuxp_dir = home / ".tmuxp" - tmuxp_dir.mkdir(parents=True) - - monkeypatch.setenv("HOME", str(home)) - monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) - monkeypatch.chdir(home) - monkeypatch.setattr(pathlib.Path, "home", lambda: home) - monkeypatch.delenv("TMUXP_CONFIGDIR", raising=False) - - (tmuxp_dir / "workspace.yaml").write_text("session_name: test\nwindows: []") - - with contextlib.suppress(SystemExit): - cli.cli(["--color=never", "ls"]) - - output = capsys.readouterr().out - - # Header should include the active directory - assert "Global workspaces (~/.tmuxp):" in output - - def test_ls_json_includes_global_workspace_dirs( - self, - monkeypatch: pytest.MonkeyPatch, - tmp_path: pathlib.Path, - capsys: pytest.CaptureFixture[str], - ) -> None: - """JSON output includes global_workspace_dirs array.""" - home = tmp_path / "home" - tmuxp_dir = home / ".tmuxp" - tmuxp_dir.mkdir(parents=True) - - monkeypatch.setenv("HOME", str(home)) - monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) - monkeypatch.chdir(home) - monkeypatch.setattr(pathlib.Path, "home", lambda: home) - monkeypatch.delenv("TMUXP_CONFIGDIR", raising=False) - - (tmuxp_dir / "workspace.yaml").write_text("session_name: test\nwindows: []") - - with contextlib.suppress(SystemExit): - cli.cli(["ls", "--json"]) - - output = capsys.readouterr().out - data = json.loads(output) - - # JSON should be an object with workspaces and global_workspace_dirs - assert isinstance(data, dict) - assert "workspaces" in data - assert "global_workspace_dirs" in data - - # Check global_workspace_dirs structure - dirs = data["global_workspace_dirs"] - assert isinstance(dirs, list) - assert len(dirs) >= 1 - - for d in dirs: - assert "path" in d - assert "source" in d - assert "exists" in d - assert "workspace_count" in d - assert "active" in d - - # Find the active one - active_dirs = [d for d in dirs if d["active"]] - assert len(active_dirs) == 1 - assert active_dirs[0]["path"] == "~/.tmuxp" - assert active_dirs[0]["exists"] is True - assert active_dirs[0]["workspace_count"] == 1 - - def test_ls_json_empty_still_has_global_workspace_dirs( - self, - monkeypatch: pytest.MonkeyPatch, - tmp_path: pathlib.Path, - capsys: pytest.CaptureFixture[str], - ) -> None: - """JSON output with no workspaces still includes global_workspace_dirs.""" - home = tmp_path / "home" - tmuxp_dir = home / ".tmuxp" - tmuxp_dir.mkdir(parents=True) # Empty directory - - monkeypatch.setenv("HOME", str(home)) - monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) - monkeypatch.chdir(home) - monkeypatch.setattr(pathlib.Path, "home", lambda: home) - monkeypatch.delenv("TMUXP_CONFIGDIR", raising=False) - - with contextlib.suppress(SystemExit): - cli.cli(["ls", "--json"]) - - output = capsys.readouterr().out - data = json.loads(output) - - assert "workspaces" in data - assert "global_workspace_dirs" in data - assert len(data["workspaces"]) == 0 - assert len(data["global_workspace_dirs"]) >= 1 - - def test_ls_xdg_takes_precedence_in_header( - self, - monkeypatch: pytest.MonkeyPatch, - tmp_path: pathlib.Path, - capsys: pytest.CaptureFixture[str], - ) -> None: - """When XDG dir exists, it shows in header instead of ~/.tmuxp.""" - home = tmp_path / "home" - xdg_tmuxp = home / ".config" / "tmuxp" - xdg_tmuxp.mkdir(parents=True) - - monkeypatch.setenv("HOME", str(home)) - monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) - monkeypatch.chdir(home) - monkeypatch.setattr(pathlib.Path, "home", lambda: home) - monkeypatch.delenv("TMUXP_CONFIGDIR", raising=False) - - (xdg_tmuxp / "workspace.yaml").write_text("session_name: test\nwindows: []") - - with contextlib.suppress(SystemExit): - cli.cli(["--color=never", "ls"]) - - output = capsys.readouterr().out - - # Header should show XDG path when it's active - assert "Global workspaces (~/.config/tmuxp):" in output - - def test_ls_tree_shows_global_workspace_dirs( - self, - monkeypatch: pytest.MonkeyPatch, - tmp_path: pathlib.Path, - capsys: pytest.CaptureFixture[str], - ) -> None: - """Tree mode also shows global workspace directories section.""" - home = tmp_path / "home" - tmuxp_dir = home / ".tmuxp" - tmuxp_dir.mkdir(parents=True) - - monkeypatch.setenv("HOME", str(home)) - monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) - monkeypatch.chdir(home) - monkeypatch.setattr(pathlib.Path, "home", lambda: home) - monkeypatch.delenv("TMUXP_CONFIGDIR", raising=False) - - (tmuxp_dir / "workspace.yaml").write_text("session_name: test\nwindows: []") - - with contextlib.suppress(SystemExit): - cli.cli(["--color=never", "ls", "--tree"]) - - output = capsys.readouterr().out - - assert "Global workspace directories:" in output - assert "Legacy: ~/.tmuxp" in output - assert "active" in output + info = _get_workspace_info(workspace) + + assert info["name"] == "test" + assert info["session_name"] is None # Couldn't parse, so None + + +def test_ls_subparser_adds_tree_flag() -> None: + """Verify --tree argument is added.""" + import argparse + + parser = argparse.ArgumentParser() + create_ls_subparser(parser) + args = parser.parse_args(["--tree"]) + + assert args.tree is True + + +def test_ls_subparser_adds_json_flag() -> None: + """Verify --json argument is added.""" + import argparse + + parser = argparse.ArgumentParser() + create_ls_subparser(parser) + args = parser.parse_args(["--json"]) + + assert args.output_json is True + + +def test_ls_subparser_adds_ndjson_flag() -> None: + """Verify --ndjson argument is added.""" + import argparse + + parser = argparse.ArgumentParser() + create_ls_subparser(parser) + args = parser.parse_args(["--ndjson"]) + + assert args.output_ndjson is True + + +def test_ls_cli( + isolated_home: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """CLI test for tmuxp ls.""" + filenames = [ + ".git/", + ".gitignore/", + "session_1.yaml", + "session_2.yaml", + "session_3.json", + "session_4.txt", + ] + + # should ignore: + # - directories should be ignored + # - extensions not covered in VALID_WORKSPACE_DIR_FILE_EXTENSIONS + ignored_filenames = [".git/", ".gitignore/", "session_4.txt"] + stems = [pathlib.Path(f).stem for f in filenames if f not in ignored_filenames] + + for filename in filenames: + location = isolated_home / f".tmuxp/{filename}" + if filename.endswith("/"): + location.mkdir(parents=True) + else: + location.touch() + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls"]) + + cli_output = capsys.readouterr().out + + # Output now has headers with directory path, check for workspace names + assert "Global workspaces (~/.tmuxp):" in cli_output + for stem in stems: + assert stem in cli_output + + +def test_ls_json_output( + isolated_home: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """CLI test for tmuxp ls --json.""" + tmuxp_dir = isolated_home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + (tmuxp_dir / "dev.yaml").write_text("session_name: development\nwindows: []") + (tmuxp_dir / "prod.json").write_text('{"session_name": "production"}') + + with contextlib.suppress(SystemExit): + cli.cli(["ls", "--json"]) + + output = capsys.readouterr().out + data = json.loads(output) + + # JSON output is now an object with workspaces and global_workspace_dirs + assert isinstance(data, dict) + assert "workspaces" in data + assert "global_workspace_dirs" in data + + workspaces = data["workspaces"] + assert len(workspaces) == 2 + + names = {item["name"] for item in workspaces} + assert names == {"dev", "prod"} + + # Verify all expected fields are present + for item in workspaces: + assert "name" in item + assert "path" in item + assert "format" in item + assert "size" in item + assert "mtime" in item + assert "session_name" in item + assert "source" in item + assert item["source"] == "global" + + +def test_ls_ndjson_output( + isolated_home: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """CLI test for tmuxp ls --ndjson.""" + tmuxp_dir = isolated_home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + (tmuxp_dir / "ws1.yaml").write_text("session_name: s1\nwindows: []") + (tmuxp_dir / "ws2.yaml").write_text("session_name: s2\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["ls", "--ndjson"]) + + output = capsys.readouterr().out + lines = [line for line in output.strip().split("\n") if line] + + assert len(lines) == 2 + + # Each line should be valid JSON + for line in lines: + data = json.loads(line) + assert "name" in data + assert "session_name" in data + assert "source" in data + + +def test_ls_tree_output( + isolated_home: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """CLI test for tmuxp ls --tree.""" + tmuxp_dir = isolated_home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + (tmuxp_dir / "dev.yaml").write_text("session_name: development\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls", "--tree"]) + + output = capsys.readouterr().out + + # Tree mode shows directory header + assert "~/.tmuxp" in output + # And indented workspace name + assert "dev" in output + + +def test_ls_empty_directory( + isolated_home: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """CLI test for tmuxp ls with no workspaces.""" + tmuxp_dir = isolated_home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls"]) + + output = capsys.readouterr().out + assert "No workspaces found" in output + + +def test_ls_tree_shows_session_name_if_different( + isolated_home: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Tree mode shows session_name if it differs from file name.""" + tmuxp_dir = isolated_home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + # File named "myfile" but session is "actual-session" + (tmuxp_dir / "myfile.yaml").write_text("session_name: actual-session\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls", "--tree"]) + + output = capsys.readouterr().out + + assert "myfile" in output + assert "actual-session" in output + + +def test_ls_finds_local_workspace_in_cwd( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Ls should find .tmuxp.yaml in current directory.""" + home = tmp_path / "home" + project = home / "project" + project.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(project) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + + (project / ".tmuxp.yaml").write_text("session_name: local\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls"]) + + output = capsys.readouterr().out + assert "Local workspaces:" in output + assert ".tmuxp" in output + + +def test_ls_finds_local_workspace_in_parent( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Ls should find .tmuxp.yaml in parent directory.""" + home = tmp_path / "home" + project = home / "project" + subdir = project / "src" / "module" + subdir.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(subdir) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + + (project / ".tmuxp.yaml").write_text("session_name: parent-local\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls"]) + + output = capsys.readouterr().out + assert "Local workspaces:" in output + assert ".tmuxp" in output + + +def test_ls_shows_local_and_global( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Ls should show both local and global workspaces.""" + home = tmp_path / "home" + project = home / "project" + project.mkdir(parents=True) + tmuxp_dir = home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(project) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + + # Local workspace + (project / ".tmuxp.yaml").write_text("session_name: local\nwindows: []") + # Global workspace + (tmuxp_dir / "global.yaml").write_text("session_name: global\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls"]) + + output = capsys.readouterr().out + assert "Local workspaces:" in output + assert "Global workspaces (~/.tmuxp):" in output + assert ".tmuxp" in output + assert "global" in output + + +def test_ls_json_includes_source_for_local( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """JSON output should include source=local for local workspaces.""" + home = tmp_path / "home" + project = home / "project" + project.mkdir(parents=True) + tmuxp_dir = home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(project) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + + (project / ".tmuxp.yaml").write_text("session_name: local\nwindows: []") + (tmuxp_dir / "global.yaml").write_text("session_name: global\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["ls", "--json"]) + + output = capsys.readouterr().out + data = json.loads(output) + + # JSON output is now an object with workspaces and global_workspace_dirs + assert isinstance(data, dict) + workspaces = data["workspaces"] + + sources = {item["source"] for item in workspaces} + assert sources == {"local", "global"} + + local_items = [item for item in workspaces if item["source"] == "local"] + global_items = [item for item in workspaces if item["source"] == "global"] + + assert len(local_items) == 1 + assert len(global_items) == 1 + assert local_items[0]["session_name"] == "local" + assert global_items[0]["session_name"] == "global" + + +def test_ls_local_shows_path( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Local workspaces should show their path in flat mode.""" + home = tmp_path / "home" + project = home / "project" + project.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(project) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + + (project / ".tmuxp.yaml").write_text("session_name: local\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls"]) + + output = capsys.readouterr().out + # Local workspace output shows path (with ~ contraction) + assert "~/project/.tmuxp.yaml" in output + + +def test_ls_full_flag_subparser() -> None: + """Verify --full argument is added to subparser.""" + import argparse + + from tmuxp.cli.ls import create_ls_subparser + + parser = argparse.ArgumentParser() + create_ls_subparser(parser) + args = parser.parse_args(["--full"]) + + assert args.full is True + + +def test_get_workspace_info_include_config(tmp_path: pathlib.Path) -> None: + """Test _get_workspace_info with include_config=True.""" + workspace = tmp_path / "test.yaml" + workspace.write_text("session_name: test\nwindows:\n - window_name: editor\n") + + info = _get_workspace_info(workspace, include_config=True) + + assert "config" in info + assert info["config"]["session_name"] == "test" + assert len(info["config"]["windows"]) == 1 + + +def test_get_workspace_info_no_config_by_default(tmp_path: pathlib.Path) -> None: + """Test _get_workspace_info without include_config doesn't include config.""" + workspace = tmp_path / "test.yaml" + workspace.write_text("session_name: test\nwindows: []\n") + + info = _get_workspace_info(workspace) + + assert "config" not in info + + +def test_ls_json_full_includes_config( + isolated_home: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """JSON output with --full includes config content.""" + tmuxp_dir = isolated_home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + (tmuxp_dir / "dev.yaml").write_text( + "session_name: dev\n" + "windows:\n" + " - window_name: editor\n" + " panes:\n" + " - vim\n" + ) + + with contextlib.suppress(SystemExit): + cli.cli(["ls", "--json", "--full"]) + + output = capsys.readouterr().out + data = json.loads(output) + + # JSON output is now an object with workspaces and global_workspace_dirs + assert isinstance(data, dict) + workspaces = data["workspaces"] + + assert len(workspaces) == 1 + assert "config" in workspaces[0] + assert workspaces[0]["config"]["session_name"] == "dev" + assert workspaces[0]["config"]["windows"][0]["window_name"] == "editor" + + +def test_ls_full_tree_shows_windows( + isolated_home: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Tree mode with --full shows window/pane hierarchy.""" + tmuxp_dir = isolated_home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + (tmuxp_dir / "dev.yaml").write_text( + "session_name: dev\n" + "windows:\n" + " - window_name: editor\n" + " layout: main-horizontal\n" + " panes:\n" + " - vim\n" + " - window_name: shell\n" + ) + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls", "--tree", "--full"]) + + output = capsys.readouterr().out + + assert "dev" in output + assert "editor" in output + assert "main-horizontal" in output + assert "shell" in output + assert "pane 0" in output + + +def test_ls_full_flat_shows_windows( + isolated_home: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Flat mode with --full shows window/pane hierarchy.""" + tmuxp_dir = isolated_home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + (tmuxp_dir / "dev.yaml").write_text( + "session_name: dev\nwindows:\n - window_name: code\n panes:\n - nvim\n" + ) + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls", "--full"]) + + output = capsys.readouterr().out + + assert "Global workspaces (~/.tmuxp):" in output + assert "dev" in output + assert "code" in output + assert "pane 0" in output + + +def test_ls_full_without_json_no_config_in_output( + isolated_home: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Non-JSON with --full shows tree but not raw config.""" + tmuxp_dir = isolated_home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + (tmuxp_dir / "dev.yaml").write_text( + "session_name: dev\nwindows:\n - window_name: editor\n" + ) + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls", "--full"]) + + output = capsys.readouterr().out + + # Should show tree structure, not raw config keys + assert "editor" in output + assert "session_name:" not in output # Raw YAML not in output + + +def test_ls_shows_global_workspace_dirs_section( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Human output shows global workspace directories section.""" + home = tmp_path / "home" + tmuxp_dir = home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(home) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + monkeypatch.delenv("TMUXP_CONFIGDIR", raising=False) + + (tmuxp_dir / "workspace.yaml").write_text("session_name: test\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls"]) + + output = capsys.readouterr().out + + assert "Global workspace directories:" in output + assert "Legacy: ~/.tmuxp" in output + assert "1 workspace" in output + assert "active" in output + assert "~/.config/tmuxp" in output + assert "not found" in output + + +def test_ls_global_header_shows_active_dir( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Global workspaces header shows active directory path.""" + home = tmp_path / "home" + tmuxp_dir = home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(home) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + monkeypatch.delenv("TMUXP_CONFIGDIR", raising=False) + + (tmuxp_dir / "workspace.yaml").write_text("session_name: test\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls"]) + + output = capsys.readouterr().out + + # Header should include the active directory + assert "Global workspaces (~/.tmuxp):" in output + + +def test_ls_json_includes_global_workspace_dirs( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """JSON output includes global_workspace_dirs array.""" + home = tmp_path / "home" + tmuxp_dir = home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(home) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + monkeypatch.delenv("TMUXP_CONFIGDIR", raising=False) + + (tmuxp_dir / "workspace.yaml").write_text("session_name: test\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["ls", "--json"]) + + output = capsys.readouterr().out + data = json.loads(output) + + # JSON should be an object with workspaces and global_workspace_dirs + assert isinstance(data, dict) + assert "workspaces" in data + assert "global_workspace_dirs" in data + + # Check global_workspace_dirs structure + dirs = data["global_workspace_dirs"] + assert isinstance(dirs, list) + assert len(dirs) >= 1 + + for d in dirs: + assert "path" in d + assert "source" in d + assert "exists" in d + assert "workspace_count" in d + assert "active" in d + + # Find the active one + active_dirs = [d for d in dirs if d["active"]] + assert len(active_dirs) == 1 + assert active_dirs[0]["path"] == "~/.tmuxp" + assert active_dirs[0]["exists"] is True + assert active_dirs[0]["workspace_count"] == 1 + + +def test_ls_json_empty_still_has_global_workspace_dirs( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """JSON output with no workspaces still includes global_workspace_dirs.""" + home = tmp_path / "home" + tmuxp_dir = home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) # Empty directory + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(home) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + monkeypatch.delenv("TMUXP_CONFIGDIR", raising=False) + + with contextlib.suppress(SystemExit): + cli.cli(["ls", "--json"]) + + output = capsys.readouterr().out + data = json.loads(output) + + assert "workspaces" in data + assert "global_workspace_dirs" in data + assert len(data["workspaces"]) == 0 + assert len(data["global_workspace_dirs"]) >= 1 + + +def test_ls_xdg_takes_precedence_in_header( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """When XDG dir exists, it shows in header instead of ~/.tmuxp.""" + home = tmp_path / "home" + xdg_tmuxp = home / ".config" / "tmuxp" + xdg_tmuxp.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(home) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + monkeypatch.delenv("TMUXP_CONFIGDIR", raising=False) + + (xdg_tmuxp / "workspace.yaml").write_text("session_name: test\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls"]) + + output = capsys.readouterr().out + + # Header should show XDG path when it's active + assert "Global workspaces (~/.config/tmuxp):" in output + + +def test_ls_tree_shows_global_workspace_dirs( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Tree mode also shows global workspace directories section.""" + home = tmp_path / "home" + tmuxp_dir = home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(home) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + monkeypatch.delenv("TMUXP_CONFIGDIR", raising=False) + + (tmuxp_dir / "workspace.yaml").write_text("session_name: test\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls", "--tree"]) + + output = capsys.readouterr().out + + assert "Global workspace directories:" in output + assert "Legacy: ~/.tmuxp" in output + assert "active" in output diff --git a/tests/cli/test_output.py b/tests/cli/test_output.py index 928a67e796..3112f60d02 100644 --- a/tests/cli/test_output.py +++ b/tests/cli/test_output.py @@ -11,241 +11,232 @@ from tmuxp.cli._output import OutputFormatter, OutputMode, get_output_mode -class TestOutputMode: - """Tests for OutputMode enum.""" - - def test_output_mode_values(self) -> None: - """Verify OutputMode enum values.""" - assert OutputMode.HUMAN.value == "human" - assert OutputMode.JSON.value == "json" - assert OutputMode.NDJSON.value == "ndjson" - - def test_output_mode_members(self) -> None: - """Verify all expected members exist.""" - members = list(OutputMode) - assert len(members) == 3 - assert OutputMode.HUMAN in members - assert OutputMode.JSON in members - assert OutputMode.NDJSON in members - +def test_output_mode_values() -> None: + """Verify OutputMode enum values.""" + assert OutputMode.HUMAN.value == "human" + assert OutputMode.JSON.value == "json" + assert OutputMode.NDJSON.value == "ndjson" -class TestGetOutputMode: - """Tests for get_output_mode function.""" - def test_default_is_human(self) -> None: - """Default mode should be HUMAN when no flags.""" - assert get_output_mode(json_flag=False, ndjson_flag=False) == OutputMode.HUMAN - - def test_json_flag(self) -> None: - """JSON flag should return JSON mode.""" - assert get_output_mode(json_flag=True, ndjson_flag=False) == OutputMode.JSON - - def test_ndjson_flag(self) -> None: - """NDJSON flag should return NDJSON mode.""" - assert get_output_mode(json_flag=False, ndjson_flag=True) == OutputMode.NDJSON - - def test_ndjson_takes_precedence(self) -> None: - """NDJSON should take precedence when both flags set.""" - assert get_output_mode(json_flag=True, ndjson_flag=True) == OutputMode.NDJSON - - -class TestOutputFormatter: - """Tests for OutputFormatter class.""" - - def test_default_mode_is_human(self) -> None: - """Default mode should be HUMAN.""" - formatter = OutputFormatter() - assert formatter.mode == OutputMode.HUMAN - - def test_explicit_mode(self) -> None: - """Mode can be set explicitly.""" - formatter = OutputFormatter(OutputMode.JSON) - assert formatter.mode == OutputMode.JSON - - def test_json_buffer_initially_empty(self) -> None: - """JSON buffer should start empty.""" - formatter = OutputFormatter(OutputMode.JSON) - assert formatter._json_buffer == [] - - -class TestOutputFormatterEmit: - """Tests for OutputFormatter.emit method.""" - - def test_emit_json_buffers_data(self) -> None: - """JSON mode should buffer data.""" - formatter = OutputFormatter(OutputMode.JSON) - formatter.emit({"name": "test1"}) - formatter.emit({"name": "test2"}) - assert len(formatter._json_buffer) == 2 - assert formatter._json_buffer[0] == {"name": "test1"} - assert formatter._json_buffer[1] == {"name": "test2"} - - def test_emit_human_does_nothing(self) -> None: - """HUMAN mode emit should not buffer or output.""" - formatter = OutputFormatter(OutputMode.HUMAN) - formatter.emit({"name": "test"}) - assert formatter._json_buffer == [] - - def test_emit_ndjson_writes_immediately( - self, capsys: pytest.CaptureFixture[str] - ) -> None: - """NDJSON mode should write one JSON object per line immediately.""" - formatter = OutputFormatter(OutputMode.NDJSON) - formatter.emit({"name": "test1", "value": 42}) - formatter.emit({"name": "test2", "value": 43}) - - captured = capsys.readouterr() - lines = captured.out.strip().split("\n") - assert len(lines) == 2 - assert json.loads(lines[0]) == {"name": "test1", "value": 42} - assert json.loads(lines[1]) == {"name": "test2", "value": 43} - - -class TestOutputFormatterEmitText: - """Tests for OutputFormatter.emit_text method.""" - - def test_emit_text_human_outputs(self, capsys: pytest.CaptureFixture[str]) -> None: - """HUMAN mode should output text.""" - formatter = OutputFormatter(OutputMode.HUMAN) - formatter.emit_text("Hello, world!") - - captured = capsys.readouterr() - assert captured.out == "Hello, world!\n" - - def test_emit_text_json_silent(self, capsys: pytest.CaptureFixture[str]) -> None: - """JSON mode should not output text.""" - formatter = OutputFormatter(OutputMode.JSON) - formatter.emit_text("Hello, world!") - - captured = capsys.readouterr() - assert captured.out == "" - - def test_emit_text_ndjson_silent(self, capsys: pytest.CaptureFixture[str]) -> None: - """NDJSON mode should not output text.""" - formatter = OutputFormatter(OutputMode.NDJSON) - formatter.emit_text("Hello, world!") +def test_output_mode_members() -> None: + """Verify all expected members exist.""" + members = list(OutputMode) + assert len(members) == 3 + assert OutputMode.HUMAN in members + assert OutputMode.JSON in members + assert OutputMode.NDJSON in members - captured = capsys.readouterr() - assert captured.out == "" +def test_get_output_mode_default_is_human() -> None: + """Default mode should be HUMAN when no flags.""" + assert get_output_mode(json_flag=False, ndjson_flag=False) == OutputMode.HUMAN -class TestOutputFormatterFinalize: - """Tests for OutputFormatter.finalize method.""" - - def test_finalize_json_outputs_array( - self, capsys: pytest.CaptureFixture[str] - ) -> None: - """JSON mode finalize should output formatted array.""" - formatter = OutputFormatter(OutputMode.JSON) - formatter.emit({"name": "test1"}) - formatter.emit({"name": "test2"}) - formatter.finalize() - captured = capsys.readouterr() - data = json.loads(captured.out) - assert isinstance(data, list) - assert len(data) == 2 - assert data[0] == {"name": "test1"} - assert data[1] == {"name": "test2"} - - def test_finalize_json_clears_buffer(self) -> None: - """JSON mode finalize should clear the buffer.""" - formatter = OutputFormatter(OutputMode.JSON) - formatter.emit({"name": "test"}) - assert len(formatter._json_buffer) == 1 - - # Capture output to prevent test pollution - old_stdout = sys.stdout - sys.stdout = io.StringIO() - try: - formatter.finalize() - finally: - sys.stdout = old_stdout - - assert formatter._json_buffer == [] - - def test_finalize_json_empty_buffer_no_output( - self, capsys: pytest.CaptureFixture[str] - ) -> None: - """JSON mode finalize with empty buffer should not output.""" - formatter = OutputFormatter(OutputMode.JSON) - formatter.finalize() +def test_get_output_mode_json_flag() -> None: + """JSON flag should return JSON mode.""" + assert get_output_mode(json_flag=True, ndjson_flag=False) == OutputMode.JSON - captured = capsys.readouterr() - assert captured.out == "" - def test_finalize_human_no_op(self, capsys: pytest.CaptureFixture[str]) -> None: - """HUMAN mode finalize should do nothing.""" - formatter = OutputFormatter(OutputMode.HUMAN) - formatter.finalize() +def test_get_output_mode_ndjson_flag() -> None: + """NDJSON flag should return NDJSON mode.""" + assert get_output_mode(json_flag=False, ndjson_flag=True) == OutputMode.NDJSON - captured = capsys.readouterr() - assert captured.out == "" - def test_finalize_ndjson_no_op(self, capsys: pytest.CaptureFixture[str]) -> None: - """NDJSON mode finalize should do nothing (already streamed).""" - formatter = OutputFormatter(OutputMode.NDJSON) - formatter.finalize() +def test_get_output_mode_ndjson_takes_precedence() -> None: + """NDJSON should take precedence when both flags set.""" + assert get_output_mode(json_flag=True, ndjson_flag=True) == OutputMode.NDJSON - captured = capsys.readouterr() - assert captured.out == "" +def test_output_formatter_default_mode_is_human() -> None: + """Default mode should be HUMAN.""" + formatter = OutputFormatter() + assert formatter.mode == OutputMode.HUMAN -class TestOutputFormatterIntegration: - """Integration tests for OutputFormatter.""" - def test_json_workflow(self, capsys: pytest.CaptureFixture[str]) -> None: - """Test complete JSON output workflow.""" - formatter = OutputFormatter(OutputMode.JSON) +def test_output_formatter_explicit_mode() -> None: + """Mode can be set explicitly.""" + formatter = OutputFormatter(OutputMode.JSON) + assert formatter.mode == OutputMode.JSON - # Emit several records - formatter.emit({"name": "workspace1", "path": "/path/1"}) - formatter.emit({"name": "workspace2", "path": "/path/2"}) - # Nothing output yet - captured = capsys.readouterr() - assert captured.out == "" +def test_output_formatter_json_buffer_initially_empty() -> None: + """JSON buffer should start empty.""" + formatter = OutputFormatter(OutputMode.JSON) + assert formatter._json_buffer == [] - # Finalize outputs everything - formatter.finalize() - captured = capsys.readouterr() - data = json.loads(captured.out) - assert len(data) == 2 - def test_ndjson_workflow(self, capsys: pytest.CaptureFixture[str]) -> None: - """Test complete NDJSON output workflow.""" - formatter = OutputFormatter(OutputMode.NDJSON) +def test_emit_json_buffers_data() -> None: + """JSON mode should buffer data.""" + formatter = OutputFormatter(OutputMode.JSON) + formatter.emit({"name": "test1"}) + formatter.emit({"name": "test2"}) + assert len(formatter._json_buffer) == 2 + assert formatter._json_buffer[0] == {"name": "test1"} + assert formatter._json_buffer[1] == {"name": "test2"} - # Each emit outputs immediately - formatter.emit({"name": "workspace1"}) - captured = capsys.readouterr() - assert json.loads(captured.out.strip()) == {"name": "workspace1"} - formatter.emit({"name": "workspace2"}) - captured = capsys.readouterr() - assert json.loads(captured.out.strip()) == {"name": "workspace2"} +def test_emit_human_does_nothing() -> None: + """HUMAN mode emit should not buffer or output.""" + formatter = OutputFormatter(OutputMode.HUMAN) + formatter.emit({"name": "test"}) + assert formatter._json_buffer == [] - # Finalize is no-op - formatter.finalize() - captured = capsys.readouterr() - assert captured.out == "" - def test_human_workflow(self, capsys: pytest.CaptureFixture[str]) -> None: - """Test complete HUMAN output workflow.""" - formatter = OutputFormatter(OutputMode.HUMAN) +def test_emit_ndjson_writes_immediately(capsys: pytest.CaptureFixture[str]) -> None: + """NDJSON mode should write one JSON object per line immediately.""" + formatter = OutputFormatter(OutputMode.NDJSON) + formatter.emit({"name": "test1", "value": 42}) + formatter.emit({"name": "test2", "value": 43}) + + captured = capsys.readouterr() + lines = captured.out.strip().split("\n") + assert len(lines) == 2 + assert json.loads(lines[0]) == {"name": "test1", "value": 42} + assert json.loads(lines[1]) == {"name": "test2", "value": 43} + + +def test_emit_text_human_outputs(capsys: pytest.CaptureFixture[str]) -> None: + """HUMAN mode should output text.""" + formatter = OutputFormatter(OutputMode.HUMAN) + formatter.emit_text("Hello, world!") + + captured = capsys.readouterr() + assert captured.out == "Hello, world!\n" + - # emit does nothing in human mode - formatter.emit({"name": "ignored"}) +def test_emit_text_json_silent(capsys: pytest.CaptureFixture[str]) -> None: + """JSON mode should not output text.""" + formatter = OutputFormatter(OutputMode.JSON) + formatter.emit_text("Hello, world!") - # emit_text outputs text - formatter.emit_text("Workspace: test") - formatter.emit_text(" Path: /path/to/test") + captured = capsys.readouterr() + assert captured.out == "" - captured = capsys.readouterr() - assert "Workspace: test" in captured.out - assert "Path: /path/to/test" in captured.out - # Finalize is no-op +def test_emit_text_ndjson_silent(capsys: pytest.CaptureFixture[str]) -> None: + """NDJSON mode should not output text.""" + formatter = OutputFormatter(OutputMode.NDJSON) + formatter.emit_text("Hello, world!") + + captured = capsys.readouterr() + assert captured.out == "" + + +def test_finalize_json_outputs_array(capsys: pytest.CaptureFixture[str]) -> None: + """JSON mode finalize should output formatted array.""" + formatter = OutputFormatter(OutputMode.JSON) + formatter.emit({"name": "test1"}) + formatter.emit({"name": "test2"}) + formatter.finalize() + + captured = capsys.readouterr() + data = json.loads(captured.out) + assert isinstance(data, list) + assert len(data) == 2 + assert data[0] == {"name": "test1"} + assert data[1] == {"name": "test2"} + + +def test_finalize_json_clears_buffer() -> None: + """JSON mode finalize should clear the buffer.""" + formatter = OutputFormatter(OutputMode.JSON) + formatter.emit({"name": "test"}) + assert len(formatter._json_buffer) == 1 + + # Capture output to prevent test pollution + old_stdout = sys.stdout + sys.stdout = io.StringIO() + try: formatter.finalize() - captured = capsys.readouterr() - assert captured.out == "" + finally: + sys.stdout = old_stdout + + assert formatter._json_buffer == [] + + +def test_finalize_json_empty_buffer_no_output( + capsys: pytest.CaptureFixture[str], +) -> None: + """JSON mode finalize with empty buffer should not output.""" + formatter = OutputFormatter(OutputMode.JSON) + formatter.finalize() + + captured = capsys.readouterr() + assert captured.out == "" + + +def test_finalize_human_no_op(capsys: pytest.CaptureFixture[str]) -> None: + """HUMAN mode finalize should do nothing.""" + formatter = OutputFormatter(OutputMode.HUMAN) + formatter.finalize() + + captured = capsys.readouterr() + assert captured.out == "" + + +def test_finalize_ndjson_no_op(capsys: pytest.CaptureFixture[str]) -> None: + """NDJSON mode finalize should do nothing (already streamed).""" + formatter = OutputFormatter(OutputMode.NDJSON) + formatter.finalize() + + captured = capsys.readouterr() + assert captured.out == "" + + +def test_json_workflow(capsys: pytest.CaptureFixture[str]) -> None: + """Test complete JSON output workflow.""" + formatter = OutputFormatter(OutputMode.JSON) + + # Emit several records + formatter.emit({"name": "workspace1", "path": "/path/1"}) + formatter.emit({"name": "workspace2", "path": "/path/2"}) + + # Nothing output yet + captured = capsys.readouterr() + assert captured.out == "" + + # Finalize outputs everything + formatter.finalize() + captured = capsys.readouterr() + data = json.loads(captured.out) + assert len(data) == 2 + + +def test_ndjson_workflow(capsys: pytest.CaptureFixture[str]) -> None: + """Test complete NDJSON output workflow.""" + formatter = OutputFormatter(OutputMode.NDJSON) + + # Each emit outputs immediately + formatter.emit({"name": "workspace1"}) + captured = capsys.readouterr() + assert json.loads(captured.out.strip()) == {"name": "workspace1"} + + formatter.emit({"name": "workspace2"}) + captured = capsys.readouterr() + assert json.loads(captured.out.strip()) == {"name": "workspace2"} + + # Finalize is no-op + formatter.finalize() + captured = capsys.readouterr() + assert captured.out == "" + + +def test_human_workflow(capsys: pytest.CaptureFixture[str]) -> None: + """Test complete HUMAN output workflow.""" + formatter = OutputFormatter(OutputMode.HUMAN) + + # emit does nothing in human mode + formatter.emit({"name": "ignored"}) + + # emit_text outputs text + formatter.emit_text("Workspace: test") + formatter.emit_text(" Path: /path/to/test") + + captured = capsys.readouterr() + assert "Workspace: test" in captured.out + assert "Path: /path/to/test" in captured.out + + # Finalize is no-op + formatter.finalize() + captured = capsys.readouterr() + assert captured.out == "" diff --git a/tests/cli/test_search.py b/tests/cli/test_search.py index cf996a12ba..21c99ff05e 100644 --- a/tests/cli/test_search.py +++ b/tests/cli/test_search.py @@ -345,495 +345,514 @@ def test_compile_search_patterns( assert bool(match) == should_match -class TestExtractWorkspaceFields: - """Tests for extract_workspace_fields.""" - - def test_basic_extraction(self, tmp_path: pathlib.Path) -> None: - """Extract fields from basic workspace file.""" - workspace = tmp_path / "test.yaml" - workspace.write_text( - "session_name: my-session\n" - "windows:\n" - " - window_name: editor\n" - " panes:\n" - " - vim\n" - " - window_name: shell\n" - ) - - fields = extract_workspace_fields(workspace) - - assert fields["name"] == "test" - assert fields["session_name"] == "my-session" - assert "editor" in fields["windows"] - assert "shell" in fields["windows"] - assert "vim" in fields["panes"] - - def test_pane_shell_command_dict(self, tmp_path: pathlib.Path) -> None: - """Extract pane commands from dict format.""" - workspace = tmp_path / "test.yaml" - workspace.write_text( - "session_name: test\n" - "windows:\n" - " - window_name: main\n" - " panes:\n" - " - shell_command: git status\n" - " - shell_command:\n" - " - npm install\n" - " - npm start\n" - ) - - fields = extract_workspace_fields(workspace) - - assert "git status" in fields["panes"] - assert "npm install" in fields["panes"] - assert "npm start" in fields["panes"] - - def test_missing_session_name(self, tmp_path: pathlib.Path) -> None: - """Handle workspace without session_name.""" - workspace = tmp_path / "test.yaml" - workspace.write_text("windows:\n - window_name: main\n") - - fields = extract_workspace_fields(workspace) - - assert fields["session_name"] == "" - assert fields["name"] == "test" - - def test_invalid_yaml(self, tmp_path: pathlib.Path) -> None: - """Handle invalid YAML gracefully.""" - workspace = tmp_path / "test.yaml" - workspace.write_text("{{{{invalid yaml") - - fields = extract_workspace_fields(workspace) - - assert fields["name"] == "test" - assert fields["session_name"] == "" - assert fields["windows"] == [] - - def test_path_uses_privacy( - self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - """Path should use PrivatePath for home contraction.""" - monkeypatch.setattr(pathlib.Path, "home", lambda: tmp_path) - workspace = tmp_path / "test.yaml" - workspace.write_text("session_name: test\n") - - fields = extract_workspace_fields(workspace) - - assert fields["path"] == "~/test.yaml" - - -class TestEvaluateMatch: - """Tests for evaluate_match function.""" - - @pytest.fixture() - def sample_fields(self) -> WorkspaceFields: - """Sample workspace fields for testing.""" - return WorkspaceFields( - name="dev-project", - path="~/.tmuxp/dev-project.yaml", - session_name="development", - windows=["editor", "shell", "logs"], - panes=["vim", "git status", "tail -f"], - ) +def test_extract_workspace_fields_basic(tmp_path: pathlib.Path) -> None: + """Extract fields from basic workspace file.""" + workspace = tmp_path / "test.yaml" + workspace.write_text( + "session_name: my-session\n" + "windows:\n" + " - window_name: editor\n" + " panes:\n" + " - vim\n" + " - window_name: shell\n" + ) - def test_single_pattern_match(self, sample_fields: WorkspaceFields) -> None: - """Single pattern should match.""" - pattern = SearchPattern( - fields=("name",), - raw="dev", - regex=re.compile("dev"), - ) + fields = extract_workspace_fields(workspace) - matched, matches = evaluate_match(sample_fields, [pattern]) + assert fields["name"] == "test" + assert fields["session_name"] == "my-session" + assert "editor" in fields["windows"] + assert "shell" in fields["windows"] + assert "vim" in fields["panes"] - assert matched is True - assert "name" in matches - def test_single_pattern_no_match(self, sample_fields: WorkspaceFields) -> None: - """Single pattern should not match.""" - pattern = SearchPattern( - fields=("name",), - raw="xyz", - regex=re.compile("xyz"), - ) +def test_extract_workspace_fields_pane_shell_command_dict( + tmp_path: pathlib.Path, +) -> None: + """Extract pane commands from dict format.""" + workspace = tmp_path / "test.yaml" + workspace.write_text( + "session_name: test\n" + "windows:\n" + " - window_name: main\n" + " panes:\n" + " - shell_command: git status\n" + " - shell_command:\n" + " - npm install\n" + " - npm start\n" + ) - matched, matches = evaluate_match(sample_fields, [pattern]) + fields = extract_workspace_fields(workspace) - assert matched is False - assert matches == {} + assert "git status" in fields["panes"] + assert "npm install" in fields["panes"] + assert "npm start" in fields["panes"] - def test_and_logic_all_match(self, sample_fields: WorkspaceFields) -> None: - """AND logic - all patterns match.""" - p1 = SearchPattern(fields=("name",), raw="dev", regex=re.compile("dev")) - p2 = SearchPattern(fields=("name",), raw="project", regex=re.compile("project")) - matched, _ = evaluate_match(sample_fields, [p1, p2], match_any=False) +def test_extract_workspace_fields_missing_session_name(tmp_path: pathlib.Path) -> None: + """Handle workspace without session_name.""" + workspace = tmp_path / "test.yaml" + workspace.write_text("windows:\n - window_name: main\n") - assert matched is True + fields = extract_workspace_fields(workspace) - def test_and_logic_partial_no_match(self, sample_fields: WorkspaceFields) -> None: - """AND logic - only some patterns match.""" - p1 = SearchPattern(fields=("name",), raw="dev", regex=re.compile("dev")) - p2 = SearchPattern(fields=("name",), raw="xyz", regex=re.compile("xyz")) + assert fields["session_name"] == "" + assert fields["name"] == "test" - matched, _ = evaluate_match(sample_fields, [p1, p2], match_any=False) - assert matched is False +def test_extract_workspace_fields_invalid_yaml(tmp_path: pathlib.Path) -> None: + """Handle invalid YAML gracefully.""" + workspace = tmp_path / "test.yaml" + workspace.write_text("{{{{invalid yaml") - def test_or_logic_any_match(self, sample_fields: WorkspaceFields) -> None: - """OR logic - any pattern matches.""" - p1 = SearchPattern(fields=("name",), raw="xyz", regex=re.compile("xyz")) - p2 = SearchPattern(fields=("name",), raw="dev", regex=re.compile("dev")) + fields = extract_workspace_fields(workspace) - matched, _ = evaluate_match(sample_fields, [p1, p2], match_any=True) + assert fields["name"] == "test" + assert fields["session_name"] == "" + assert fields["windows"] == [] + + +def test_extract_workspace_fields_path_uses_privacy( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Path should use PrivatePath for home contraction.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: tmp_path) + workspace = tmp_path / "test.yaml" + workspace.write_text("session_name: test\n") - assert matched is True + fields = extract_workspace_fields(workspace) - def test_window_field_search(self, sample_fields: WorkspaceFields) -> None: - """Search in window field.""" - pattern = SearchPattern( - fields=("window",), - raw="editor", - regex=re.compile("editor"), - ) + assert fields["path"] == "~/test.yaml" - matched, matches = evaluate_match(sample_fields, [pattern]) - assert matched is True - assert "window" in matches +@pytest.fixture() +def sample_fields() -> WorkspaceFields: + """Sample workspace fields for testing.""" + return WorkspaceFields( + name="dev-project", + path="~/.tmuxp/dev-project.yaml", + session_name="development", + windows=["editor", "shell", "logs"], + panes=["vim", "git status", "tail -f"], + ) - def test_pane_field_search(self, sample_fields: WorkspaceFields) -> None: - """Search in pane field.""" - pattern = SearchPattern( - fields=("pane",), - raw="vim", - regex=re.compile("vim"), - ) - matched, matches = evaluate_match(sample_fields, [pattern]) +def test_evaluate_match_single_pattern(sample_fields: WorkspaceFields) -> None: + """Single pattern should match.""" + pattern = SearchPattern( + fields=("name",), + raw="dev", + regex=re.compile("dev"), + ) - assert matched is True - assert "pane" in matches + matched, matches = evaluate_match(sample_fields, [pattern]) - def test_multiple_fields(self, sample_fields: WorkspaceFields) -> None: - """Pattern searches multiple fields.""" - pattern = SearchPattern( - fields=("name", "session_name"), - raw="dev", - regex=re.compile("dev"), - ) + assert matched is True + assert "name" in matches - matched, matches = evaluate_match(sample_fields, [pattern]) - assert matched is True - # Should find matches in both name and session_name - assert "name" in matches or "session_name" in matches +def test_evaluate_match_single_pattern_no_match(sample_fields: WorkspaceFields) -> None: + """Single pattern should not match.""" + pattern = SearchPattern( + fields=("name",), + raw="xyz", + regex=re.compile("xyz"), + ) + matched, matches = evaluate_match(sample_fields, [pattern]) -class TestFindSearchMatches: - """Tests for find_search_matches function.""" + assert matched is False + assert matches == {} - def test_basic_search(self, tmp_path: pathlib.Path) -> None: - """Basic search finds matching workspace.""" - workspace = tmp_path / "dev.yaml" - workspace.write_text("session_name: development\n") - pattern = SearchPattern( - fields=("session_name",), - raw="dev", - regex=re.compile("dev"), - ) +def test_evaluate_match_and_logic_all_match(sample_fields: WorkspaceFields) -> None: + """AND logic - all patterns match.""" + p1 = SearchPattern(fields=("name",), raw="dev", regex=re.compile("dev")) + p2 = SearchPattern(fields=("name",), raw="project", regex=re.compile("project")) - results = find_search_matches([(workspace, "global")], [pattern]) + matched, _ = evaluate_match(sample_fields, [p1, p2], match_any=False) - assert len(results) == 1 - assert results[0]["source"] == "global" + assert matched is True - def test_no_match(self, tmp_path: pathlib.Path) -> None: - """Search returns empty when no match.""" - workspace = tmp_path / "production.yaml" - workspace.write_text("session_name: production\n") - pattern = SearchPattern( - fields=("name",), - raw="dev", - regex=re.compile("dev"), - ) +def test_evaluate_match_and_logic_partial_no_match( + sample_fields: WorkspaceFields, +) -> None: + """AND logic - only some patterns match.""" + p1 = SearchPattern(fields=("name",), raw="dev", regex=re.compile("dev")) + p2 = SearchPattern(fields=("name",), raw="xyz", regex=re.compile("xyz")) - results = find_search_matches([(workspace, "global")], [pattern]) + matched, _ = evaluate_match(sample_fields, [p1, p2], match_any=False) - assert len(results) == 0 + assert matched is False - def test_invert_match(self, tmp_path: pathlib.Path) -> None: - """Invert match returns non-matching workspaces.""" - workspace = tmp_path / "production.yaml" - workspace.write_text("session_name: production\n") - pattern = SearchPattern( - fields=("name",), - raw="dev", - regex=re.compile("dev"), - ) +def test_evaluate_match_or_logic_any_match(sample_fields: WorkspaceFields) -> None: + """OR logic - any pattern matches.""" + p1 = SearchPattern(fields=("name",), raw="xyz", regex=re.compile("xyz")) + p2 = SearchPattern(fields=("name",), raw="dev", regex=re.compile("dev")) - results = find_search_matches( - [(workspace, "global")], [pattern], invert_match=True - ) + matched, _ = evaluate_match(sample_fields, [p1, p2], match_any=True) - assert len(results) == 1 + assert matched is True - def test_multiple_workspaces(self, tmp_path: pathlib.Path) -> None: - """Search across multiple workspaces.""" - ws1 = tmp_path / "dev.yaml" - ws1.write_text("session_name: development\n") - ws2 = tmp_path / "prod.yaml" - ws2.write_text("session_name: production\n") +def test_evaluate_match_window_field(sample_fields: WorkspaceFields) -> None: + """Search in window field.""" + pattern = SearchPattern( + fields=("window",), + raw="editor", + regex=re.compile("editor"), + ) - pattern = SearchPattern( - fields=("name", "session_name"), - raw="dev", - regex=re.compile("dev"), - ) + matched, matches = evaluate_match(sample_fields, [pattern]) - results = find_search_matches([(ws1, "global"), (ws2, "global")], [pattern]) - - assert len(results) == 1 - assert results[0]["fields"]["name"] == "dev" + assert matched is True + assert "window" in matches -class TestHighlightMatches: - """Tests for highlight_matches function.""" - - def test_no_colors(self) -> None: - """Colors disabled returns original text.""" - colors = Colors(ColorMode.NEVER) - pattern = SearchPattern( - fields=("name",), - raw="dev", - regex=re.compile("dev"), - ) - - result = highlight_matches("development", [pattern], colors=colors) - - assert result == "development" - - def test_with_colors(self) -> None: - """Colors enabled adds ANSI codes.""" - colors = Colors(ColorMode.ALWAYS) - pattern = SearchPattern( - fields=("name",), - raw="dev", - regex=re.compile("dev"), - ) - - result = highlight_matches("development", [pattern], colors=colors) - - assert "\033[" in result # Contains ANSI escape - assert "dev" in result - - def test_no_match(self) -> None: - """No match returns original text.""" - colors = Colors(ColorMode.ALWAYS) - pattern = SearchPattern( - fields=("name",), - raw="xyz", - regex=re.compile("xyz"), - ) - - result = highlight_matches("development", [pattern], colors=colors) - - assert result == "development" - - def test_multiple_matches(self) -> None: - """Multiple matches in same string.""" - colors = Colors(ColorMode.ALWAYS) - pattern = SearchPattern( - fields=("name",), - raw="e", - regex=re.compile("e"), - ) - - result = highlight_matches("development", [pattern], colors=colors) - - # Should contain multiple highlights - assert result.count("\033[") > 1 - - def test_empty_patterns(self) -> None: - """Empty patterns returns original text.""" - colors = Colors(ColorMode.ALWAYS) - - result = highlight_matches("development", [], colors=colors) - - assert result == "development" - - -class TestGetFieldValues: - """Tests for _get_field_values helper.""" - - @pytest.fixture() - def sample_fields(self) -> WorkspaceFields: - """Sample workspace fields.""" - return WorkspaceFields( - name="test", - path="~/.tmuxp/test.yaml", - session_name="test-session", - windows=["editor", "shell"], - panes=["vim", "bash"], - ) - - def test_scalar_field(self, sample_fields: WorkspaceFields) -> None: - """Scalar field returns list with one item.""" - result = _get_field_values(sample_fields, "name") - assert result == ["test"] - - def test_list_field(self, sample_fields: WorkspaceFields) -> None: - """List field returns the list.""" - result = _get_field_values(sample_fields, "windows") - assert result == ["editor", "shell"] - - def test_window_alias(self, sample_fields: WorkspaceFields) -> None: - """Window alias maps to windows.""" - result = _get_field_values(sample_fields, "window") - assert result == ["editor", "shell"] - - def test_pane_alias(self, sample_fields: WorkspaceFields) -> None: - """Pane alias maps to panes.""" - result = _get_field_values(sample_fields, "pane") - assert result == ["vim", "bash"] - - def test_empty_value(self) -> None: - """Empty value returns empty list.""" - fields = WorkspaceFields( - name="", - path="", - session_name="", - windows=[], - panes=[], - ) - result = _get_field_values(fields, "name") - assert result == [] +def test_evaluate_match_pane_field(sample_fields: WorkspaceFields) -> None: + """Search in pane field.""" + pattern = SearchPattern( + fields=("pane",), + raw="vim", + regex=re.compile("vim"), + ) + + matched, matches = evaluate_match(sample_fields, [pattern]) + + assert matched is True + assert "pane" in matches + + +def test_evaluate_match_multiple_fields(sample_fields: WorkspaceFields) -> None: + """Pattern searches multiple fields.""" + pattern = SearchPattern( + fields=("name", "session_name"), + raw="dev", + regex=re.compile("dev"), + ) + + matched, matches = evaluate_match(sample_fields, [pattern]) + + assert matched is True + # Should find matches in both name and session_name + assert "name" in matches or "session_name" in matches + + +def test_find_search_matches_basic(tmp_path: pathlib.Path) -> None: + """Basic search finds matching workspace.""" + workspace = tmp_path / "dev.yaml" + workspace.write_text("session_name: development\n") + + pattern = SearchPattern( + fields=("session_name",), + raw="dev", + regex=re.compile("dev"), + ) + + results = find_search_matches([(workspace, "global")], [pattern]) + + assert len(results) == 1 + assert results[0]["source"] == "global" -class TestSearchSubparser: - """Tests for search subparser configuration.""" +def test_find_search_matches_no_match(tmp_path: pathlib.Path) -> None: + """Search returns empty when no match.""" + workspace = tmp_path / "production.yaml" + workspace.write_text("session_name: production\n") - def test_parser_creation(self) -> None: - """Subparser can be created successfully.""" - import argparse + pattern = SearchPattern( + fields=("name",), + raw="dev", + regex=re.compile("dev"), + ) + + results = find_search_matches([(workspace, "global")], [pattern]) + + assert len(results) == 0 + + +def test_find_search_matches_invert(tmp_path: pathlib.Path) -> None: + """Invert match returns non-matching workspaces.""" + workspace = tmp_path / "production.yaml" + workspace.write_text("session_name: production\n") + + pattern = SearchPattern( + fields=("name",), + raw="dev", + regex=re.compile("dev"), + ) + + results = find_search_matches([(workspace, "global")], [pattern], invert_match=True) + + assert len(results) == 1 + + +def test_find_search_matches_multiple_workspaces(tmp_path: pathlib.Path) -> None: + """Search across multiple workspaces.""" + ws1 = tmp_path / "dev.yaml" + ws1.write_text("session_name: development\n") + + ws2 = tmp_path / "prod.yaml" + ws2.write_text("session_name: production\n") + + pattern = SearchPattern( + fields=("name", "session_name"), + raw="dev", + regex=re.compile("dev"), + ) - parser = argparse.ArgumentParser() - result = create_search_subparser(parser) + results = find_search_matches([(ws1, "global"), (ws2, "global")], [pattern]) + + assert len(results) == 1 + assert results[0]["fields"]["name"] == "dev" + + +def test_highlight_matches_no_colors() -> None: + """Colors disabled returns original text.""" + colors = Colors(ColorMode.NEVER) + pattern = SearchPattern( + fields=("name",), + raw="dev", + regex=re.compile("dev"), + ) + + result = highlight_matches("development", [pattern], colors=colors) + + assert result == "development" + + +def test_highlight_matches_with_colors() -> None: + """Colors enabled adds ANSI codes.""" + colors = Colors(ColorMode.ALWAYS) + pattern = SearchPattern( + fields=("name",), + raw="dev", + regex=re.compile("dev"), + ) + + result = highlight_matches("development", [pattern], colors=colors) + + assert "\033[" in result # Contains ANSI escape + assert "dev" in result + + +def test_highlight_matches_no_match() -> None: + """No match returns original text.""" + colors = Colors(ColorMode.ALWAYS) + pattern = SearchPattern( + fields=("name",), + raw="xyz", + regex=re.compile("xyz"), + ) - assert result is parser - - def test_parser_options(self) -> None: - """Parser has expected options.""" - import argparse + result = highlight_matches("development", [pattern], colors=colors) - parser = argparse.ArgumentParser() - create_search_subparser(parser) - - # Parse with various options - args = parser.parse_args(["-i", "-S", "-F", "-w", "-v", "--any", "pattern"]) - - assert args.ignore_case is True - assert args.smart_case is True - assert args.fixed_strings is True - assert args.word_regexp is True - assert args.invert_match is True - assert args.match_any is True - assert args.query_terms == ["pattern"] - - def test_output_format_options(self) -> None: - """Parser supports output format options.""" - import argparse - - parser = argparse.ArgumentParser() - create_search_subparser(parser) - - args_json = parser.parse_args(["--json", "test"]) - assert args_json.output_json is True - - args_ndjson = parser.parse_args(["--ndjson", "test"]) - assert args_ndjson.output_ndjson is True - - def test_field_option(self) -> None: - """Parser supports field option.""" - import argparse - - parser = argparse.ArgumentParser() - create_search_subparser(parser) - - args = parser.parse_args(["-f", "name", "-f", "session", "test"]) - - assert args.field == ["name", "session"] - - -class TestOutputSearchResults: - """Tests for _output_search_results function.""" - - def test_no_results_message(self, capsys: pytest.CaptureFixture[str]) -> None: - """No results outputs warning message.""" - colors = Colors(ColorMode.NEVER) - formatter = OutputFormatter(OutputMode.HUMAN) - - _output_search_results([], [], formatter, colors) - formatter.finalize() - - captured = capsys.readouterr() - assert "No matching" in captured.out - - def test_json_output(self, capsys: pytest.CaptureFixture[str]) -> None: - """JSON output mode produces valid JSON.""" - colors = Colors(ColorMode.NEVER) - formatter = OutputFormatter(OutputMode.JSON) - - result: WorkspaceSearchResult = { - "filepath": "/test/dev.yaml", - "source": "global", - "fields": WorkspaceFields( - name="dev", - path="~/.tmuxp/dev.yaml", - session_name="development", - windows=["editor"], - panes=["vim"], - ), - "matches": {"name": ["dev"]}, - } - - _output_search_results([result], [], formatter, colors) - formatter.finalize() - - captured = capsys.readouterr() - data = json.loads(captured.out) - assert len(data) == 1 - assert data[0]["name"] == "dev" - - def test_ndjson_output(self, capsys: pytest.CaptureFixture[str]) -> None: - """NDJSON output mode produces one JSON per line.""" - colors = Colors(ColorMode.NEVER) - formatter = OutputFormatter(OutputMode.NDJSON) - - result: WorkspaceSearchResult = { - "filepath": "/test/dev.yaml", - "source": "global", - "fields": WorkspaceFields( - name="dev", - path="~/.tmuxp/dev.yaml", - session_name="development", - windows=[], - panes=[], - ), - "matches": {"name": ["dev"]}, - } - - _output_search_results([result], [], formatter, colors) - formatter.finalize() - - captured = capsys.readouterr() - lines = captured.out.strip().split("\n") - # Filter out human-readable lines - json_lines = [line for line in lines if line.startswith("{")] - assert len(json_lines) >= 1 - data = json.loads(json_lines[0]) - assert data["name"] == "dev" + assert result == "development" + + +def test_highlight_matches_multiple() -> None: + """Multiple matches in same string.""" + colors = Colors(ColorMode.ALWAYS) + pattern = SearchPattern( + fields=("name",), + raw="e", + regex=re.compile("e"), + ) + + result = highlight_matches("development", [pattern], colors=colors) + + # Should contain multiple highlights + assert result.count("\033[") > 1 + + +def test_highlight_matches_empty_patterns() -> None: + """Empty patterns returns original text.""" + colors = Colors(ColorMode.ALWAYS) + + result = highlight_matches("development", [], colors=colors) + + assert result == "development" + + +@pytest.fixture() +def sample_fields_for_get_field_values() -> WorkspaceFields: + """Sample workspace fields.""" + return WorkspaceFields( + name="test", + path="~/.tmuxp/test.yaml", + session_name="test-session", + windows=["editor", "shell"], + panes=["vim", "bash"], + ) + + +def test_get_field_values_scalar( + sample_fields_for_get_field_values: WorkspaceFields, +) -> None: + """Scalar field returns list with one item.""" + result = _get_field_values(sample_fields_for_get_field_values, "name") + assert result == ["test"] + + +def test_get_field_values_list( + sample_fields_for_get_field_values: WorkspaceFields, +) -> None: + """List field returns the list.""" + result = _get_field_values(sample_fields_for_get_field_values, "windows") + assert result == ["editor", "shell"] + + +def test_get_field_values_window_alias( + sample_fields_for_get_field_values: WorkspaceFields, +) -> None: + """Window alias maps to windows.""" + result = _get_field_values(sample_fields_for_get_field_values, "window") + assert result == ["editor", "shell"] + + +def test_get_field_values_pane_alias( + sample_fields_for_get_field_values: WorkspaceFields, +) -> None: + """Pane alias maps to panes.""" + result = _get_field_values(sample_fields_for_get_field_values, "pane") + assert result == ["vim", "bash"] + + +def test_get_field_values_empty() -> None: + """Empty value returns empty list.""" + fields = WorkspaceFields( + name="", + path="", + session_name="", + windows=[], + panes=[], + ) + result = _get_field_values(fields, "name") + assert result == [] + + +def test_search_subparser_creation() -> None: + """Subparser can be created successfully.""" + import argparse + + parser = argparse.ArgumentParser() + result = create_search_subparser(parser) + + assert result is parser + + +def test_search_subparser_options() -> None: + """Parser has expected options.""" + import argparse + + parser = argparse.ArgumentParser() + create_search_subparser(parser) + + # Parse with various options + args = parser.parse_args(["-i", "-S", "-F", "-w", "-v", "--any", "pattern"]) + + assert args.ignore_case is True + assert args.smart_case is True + assert args.fixed_strings is True + assert args.word_regexp is True + assert args.invert_match is True + assert args.match_any is True + assert args.query_terms == ["pattern"] + + +def test_search_subparser_output_format_options() -> None: + """Parser supports output format options.""" + import argparse + + parser = argparse.ArgumentParser() + create_search_subparser(parser) + + args_json = parser.parse_args(["--json", "test"]) + assert args_json.output_json is True + + args_ndjson = parser.parse_args(["--ndjson", "test"]) + assert args_ndjson.output_ndjson is True + + +def test_search_subparser_field_option() -> None: + """Parser supports field option.""" + import argparse + + parser = argparse.ArgumentParser() + create_search_subparser(parser) + + args = parser.parse_args(["-f", "name", "-f", "session", "test"]) + + assert args.field == ["name", "session"] + + +def test_output_search_results_no_results(capsys: pytest.CaptureFixture[str]) -> None: + """No results outputs warning message.""" + colors = Colors(ColorMode.NEVER) + formatter = OutputFormatter(OutputMode.HUMAN) + + _output_search_results([], [], formatter, colors) + formatter.finalize() + + captured = capsys.readouterr() + assert "No matching" in captured.out + + +def test_output_search_results_json(capsys: pytest.CaptureFixture[str]) -> None: + """JSON output mode produces valid JSON.""" + colors = Colors(ColorMode.NEVER) + formatter = OutputFormatter(OutputMode.JSON) + + result: WorkspaceSearchResult = { + "filepath": "/test/dev.yaml", + "source": "global", + "fields": WorkspaceFields( + name="dev", + path="~/.tmuxp/dev.yaml", + session_name="development", + windows=["editor"], + panes=["vim"], + ), + "matches": {"name": ["dev"]}, + } + + _output_search_results([result], [], formatter, colors) + formatter.finalize() + + captured = capsys.readouterr() + data = json.loads(captured.out) + assert len(data) == 1 + assert data[0]["name"] == "dev" + + +def test_output_search_results_ndjson(capsys: pytest.CaptureFixture[str]) -> None: + """NDJSON output mode produces one JSON per line.""" + colors = Colors(ColorMode.NEVER) + formatter = OutputFormatter(OutputMode.NDJSON) + + result: WorkspaceSearchResult = { + "filepath": "/test/dev.yaml", + "source": "global", + "fields": WorkspaceFields( + name="dev", + path="~/.tmuxp/dev.yaml", + session_name="development", + windows=[], + panes=[], + ), + "matches": {"name": ["dev"]}, + } + + _output_search_results([result], [], formatter, colors) + formatter.finalize() + + captured = capsys.readouterr() + lines = captured.out.strip().split("\n") + # Filter out human-readable lines + json_lines = [line for line in lines if line.startswith("{")] + assert len(json_lines) >= 1 + data = json.loads(json_lines[0]) + assert data["name"] == "dev" From cecf056e782e3f0d1ef2e0aa47415cdbcc2a1f37 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 11 Jan 2026 04:21:24 -0600 Subject: [PATCH 93/99] fix(cli/load): Narrow exception handling in load_plugins() Replace bare `except Exception` with specific exception types: - First try block: `except AttributeError` for string operations on non-string plugin values - Second try block: `except (ImportError, AttributeError)` for module import failures and missing plugin class attributes This prevents catching unrelated errors like KeyboardInterrupt or MemoryError that should propagate up. --- src/tmuxp/cli/load.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index ec0e233370..3e6edbd2b7 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -119,7 +119,7 @@ def load_plugins( module_name = plugin.split(".") module_name = ".".join(module_name[:-1]) plugin_name = plugin.split(".")[-1] - except Exception as error: + except AttributeError as error: tmuxp_echo( colors.error("[Plugin Error]") + f" Couldn't load {plugin}\n" @@ -141,7 +141,7 @@ def load_plugins( + " Plugin versions constraint not met. Exiting...", ) sys.exit(1) - except Exception as error: + except (ImportError, AttributeError) as error: tmuxp_echo( colors.error("[Plugin Error]") + f" Couldn't load {plugin}\n" From 1b97bfd91bff1ab8b1d2da2d2dda1a8c76f4734c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 11 Jan 2026 04:22:23 -0600 Subject: [PATCH 94/99] fix(cli/ls): Use specific exceptions for config parse errors Replace bare `except Exception` with `except (yaml.YAMLError, json.JSONDecodeError, OSError)` in `_get_workspace_info()`. This ensures only expected errors from config file parsing are caught, allowing unrelated errors to propagate for proper debugging. --- src/tmuxp/cli/ls.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/tmuxp/cli/ls.py b/src/tmuxp/cli/ls.py index b4db4645cc..4111f5cbf1 100644 --- a/src/tmuxp/cli/ls.py +++ b/src/tmuxp/cli/ls.py @@ -28,9 +28,12 @@ import argparse import datetime +import json import pathlib import typing as t +import yaml + from tmuxp._internal.config_reader import ConfigReader from tmuxp._internal.private_path import PrivatePath from tmuxp.workspace.constants import VALID_WORKSPACE_DIR_FILE_EXTENSIONS @@ -226,7 +229,7 @@ def _get_workspace_info( session_name = config.content.get("session_name") if include_config: config_content = config.content - except Exception: + except (yaml.YAMLError, json.JSONDecodeError, OSError): # If we can't parse it, just skip session_name pass From b1dd74f85522dbf104464660d5d4388978caadf6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 11 Jan 2026 04:23:14 -0600 Subject: [PATCH 95/99] fix(cli/search): Use specific exceptions for config parse errors Replace bare `except Exception` with `except (yaml.YAMLError, json.JSONDecodeError, OSError)` in `extract_workspace_fields()`. This ensures only expected errors from config file parsing are caught, allowing unrelated errors to propagate for proper debugging. --- src/tmuxp/cli/search.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/tmuxp/cli/search.py b/src/tmuxp/cli/search.py index 24e5035bf6..ab12b59f9c 100644 --- a/src/tmuxp/cli/search.py +++ b/src/tmuxp/cli/search.py @@ -24,10 +24,13 @@ from __future__ import annotations import argparse +import json import pathlib import re import typing as t +import yaml + from tmuxp._internal.config_reader import ConfigReader from tmuxp._internal.private_path import PrivatePath from tmuxp.workspace.constants import VALID_WORKSPACE_DIR_FILE_EXTENSIONS @@ -602,7 +605,7 @@ def extract_workspace_fields(filepath: pathlib.Path) -> WorkspaceFields: panes.append(cmds) elif isinstance(cmds, list): panes.extend(str(cmd) for cmd in cmds if cmd) - except Exception: + except (yaml.YAMLError, json.JSONDecodeError, OSError): # If config parsing fails, continue with empty content fields pass From 21b8f626d1fae235dfd585181ddd6b9bfce5a0d2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 11 Jan 2026 04:24:17 -0600 Subject: [PATCH 96/99] tests(cli/conftest): Clear TMUXP_CONFIGDIR in isolated_home fixture Add `monkeypatch.delenv("TMUXP_CONFIGDIR", raising=False)` to prevent test pollution from user environment. The fixture now properly isolates tests from all workspace directory configuration sources. --- tests/cli/conftest.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py index 5c32790fbd..32e5748f21 100644 --- a/tests/cli/conftest.py +++ b/tests/cli/conftest.py @@ -51,11 +51,12 @@ def isolated_home( ) -> pathlib.Path: """Isolate test from user's home directory and environment. - Sets up tmp_path as HOME with XDG_CONFIG_HOME, clears NO_COLOR, - and changes the working directory to tmp_path. + Sets up tmp_path as HOME with XDG_CONFIG_HOME, clears TMUXP_CONFIGDIR + and NO_COLOR, and changes the working directory to tmp_path. """ monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / ".config")) + monkeypatch.delenv("TMUXP_CONFIGDIR", raising=False) monkeypatch.delenv("NO_COLOR", raising=False) monkeypatch.chdir(tmp_path) monkeypatch.setattr(pathlib.Path, "home", lambda: tmp_path) From 1c6275282ece2b384d10862cd74f9b340472cde6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 11 Jan 2026 04:25:33 -0600 Subject: [PATCH 97/99] tests(_internal/colors): Add test for heading() with colors enabled Verify that heading() applies bright_cyan (ANSI 96) with bold when colors are enabled. Previously only the disabled case was tested. Also adds ANSI_BRIGHT_CYAN constant to conftest.py for test assertions. --- tests/_internal/conftest.py | 1 + tests/_internal/test_colors.py | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/tests/_internal/conftest.py b/tests/_internal/conftest.py index 927b3f0196..c0630520dd 100644 --- a/tests/_internal/conftest.py +++ b/tests/_internal/conftest.py @@ -14,6 +14,7 @@ ANSI_BLUE = "\033[34m" ANSI_MAGENTA = "\033[35m" ANSI_CYAN = "\033[36m" +ANSI_BRIGHT_CYAN = "\033[96m" ANSI_RESET = "\033[0m" ANSI_BOLD = "\033[1m" diff --git a/tests/_internal/test_colors.py b/tests/_internal/test_colors.py index 3d6540ca4f..3aef1e8040 100644 --- a/tests/_internal/test_colors.py +++ b/tests/_internal/test_colors.py @@ -9,6 +9,7 @@ from tests._internal.conftest import ( ANSI_BLUE, ANSI_BOLD, + ANSI_BRIGHT_CYAN, ANSI_CYAN, ANSI_GREEN, ANSI_MAGENTA, @@ -297,3 +298,17 @@ def test_style_with_rgb_non_integer() -> None: """style() should reject non-integer RGB values.""" with pytest.raises(UnknownStyleColor): style("test", fg=(255.5, 128, 0)) # type: ignore[arg-type] + + +# heading() method tests + + +def test_heading_applies_bright_cyan_bold(monkeypatch: pytest.MonkeyPatch) -> None: + """heading() applies bright_cyan with bold when colors are enabled.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + result = colors.heading("Local workspaces:") + assert ANSI_BRIGHT_CYAN in result + assert ANSI_BOLD in result + assert "Local workspaces:" in result + assert ANSI_RESET in result From 945a6cb04c1800dce8d26d505d91f19ad58708f3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 11 Jan 2026 04:26:27 -0600 Subject: [PATCH 98/99] tests(cli/search): Add test for invalid regex pattern error Add test_compile_search_patterns_invalid_regex_raises to verify that compile_search_patterns() raises re.error when given an invalid regex pattern like "[invalid(". This ensures the re.error exception handling path in command_search() is properly tested. --- tests/cli/test_search.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/cli/test_search.py b/tests/cli/test_search.py index 21c99ff05e..e4140b2645 100644 --- a/tests/cli/test_search.py +++ b/tests/cli/test_search.py @@ -345,6 +345,13 @@ def test_compile_search_patterns( assert bool(match) == should_match +def test_compile_search_patterns_invalid_regex_raises() -> None: + """Invalid regex pattern raises re.error.""" + tokens = [SearchToken(fields=("name",), pattern="[invalid(")] + with pytest.raises(re.error): + compile_search_patterns(tokens) + + def test_extract_workspace_fields_basic(tmp_path: pathlib.Path) -> None: """Extract fields from basic workspace file.""" workspace = tmp_path / "test.yaml" From 8b41806927044ed45f3cf149bb5f102a975a6507 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 11 Jan 2026 05:11:57 -0600 Subject: [PATCH 99/99] fix(cli/search): Use consistent color semantics with ls command Change search output to use highlight() for workspace names (L1 primary content) and info() for paths (L2 supplementary info), matching the pattern used in ls.py per CLAUDE.md CLI Color Semantics. Before: names used info() (cyan), paths used muted() (blue) After: names use highlight() (magenta+bold), paths use info() (cyan) --- src/tmuxp/cli/search.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tmuxp/cli/search.py b/src/tmuxp/cli/search.py index ab12b59f9c..840e48a4a7 100644 --- a/src/tmuxp/cli/search.py +++ b/src/tmuxp/cli/search.py @@ -984,8 +984,8 @@ def output_result(result: WorkspaceSearchResult, show_path: bool) -> None: # Human output: formatted text with highlighting name_display = highlight_matches(fields["name"], patterns, colors=colors) - path_info = f" {colors.muted(fields['path'])}" if show_path else "" - formatter.emit_text(f" {colors.info(name_display)}{path_info}") + path_info = f" {colors.info(fields['path'])}" if show_path else "" + formatter.emit_text(f" {colors.highlight(name_display)}{path_info}") # Show matched session_name if different from name session_name = fields["session_name"]