diff --git a/src/together/lib/cli/utils/_console.py b/src/together/lib/cli/utils/_console.py index ad896db9..534e61fa 100644 --- a/src/together/lib/cli/utils/_console.py +++ b/src/together/lib/cli/utils/_console.py @@ -1,33 +1,117 @@ +from __future__ import annotations + +import os +from typing import Literal + from rich.theme import Theme from rich.console import Console -custom_theme = Theme( - { - # Text styles - "primary": "#caaef5", # Purple 300 ⭐ (lighter when bold) - "secondary": "dim #caaef5", # Purple 500 ⭐ (mid-tone without bold) - "accent": "#ff68d4", # Pink 500 ⭐ - "muted": "#98a0b3", # Grey 400 ⭐ - # Semantic styles - "success": "bold #0dce74", # Green 400 ⭐ - "info": "#64afff", # Blue 500 ⭐ - "warning": "bold #ff815d", # Red 500 ⭐ - "error": "bold #c63800", # Red 700 ⭐ - # UI elements - "prompt": "#ba92ff", # Purple 500 ⭐ (no bold) - "prompt.choices": "#caaef5", # Purple 300 ⭐ - "prompt.default": "dim #98a0b3", # Grey 400 ⭐ - # Table styles - "table.header": "#414858", # Purple 300 ⭐ (lighter when bold) - "table.border": "#626b84", # Grey 600 ⭐ - "table.row": "#c4c9d4", # Grey 300 ⭐ - # Progress/Loading - "progress.description": "#caaef5", # Purple 300 ⭐ - "progress.percentage": "bold #caaef5", # Purple 300 ⭐ (lighter when bold) - "bar.complete": "#ba92ff", # Purple 500 ⭐ (no bold) - "bar.finished": "#0dce74", # Green 400 ⭐ - "bar.pulse": "#ff68d4", # Pink 500 ⭐ - } -) - -console = Console(theme=custom_theme, highlight=False) +CliThemeName = Literal["light", "dark"] + +# Dark theme: tuned for dark terminal backgrounds (original Together CLI palette). +_DARK_STYLES = { + # Text styles + "primary": "#caaef5", # Purple 300 + "secondary": "#caaef5", # solid (no dim) so help body text stays readable + "accent": "#ff68d4", # Pink 500 + "muted": "#98a0b3", # Grey 400 + # Semantic styles + "success": "bold #0dce74", # Green 400 + "info": "#64afff", # Blue 500 + "warning": "bold #ff815d", # Red 500 + "error": "bold #c63800", # Red 700 + # UI elements + "prompt": "#ba92ff", # Purple 500 + "prompt.choices": "#caaef5", # Purple 300 + "prompt.default": "#98a0b3", # Grey 400 + # Table styles + "table.header": "#414858", + "table.border": "#626b84", # Grey 600 + "table.row": "#c4c9d4", # Grey 300 + # Progress/Loading + "progress.description": "#caaef5", # Purple 300 + "progress.percentage": "bold #caaef5", + "bar.complete": "#ba92ff", # Purple 500 + "bar.finished": "#0dce74", # Green 400 + "bar.pulse": "#ff68d4", # Pink 500 +} + +# Light theme: high-contrast on white. Body copy uses near-black; brand purple is +# reserved for accents and kept dark enough for AA+. `[dim]` / `[white]` markup +# resolves through the theme, so overrides fix help examples and pagination +# without touching every call site. +_LIGHT_STYLES = { + # Text styles — body text intentionally near-black (purple mid-tones still wash out) + "primary": "bold #4c1d95", # Violet 900 (~11:1 on white) + "secondary": "#111827", # Grey 900 — types / secondary labels + "accent": "#9d174d", # Pink 800 + "muted": "#1f2937", # Grey 800 — descriptions / supporting copy + # Replace ANSI dim / bright-white (both fail on light backgrounds) + "dim": "#1f2937", # Grey 800 + "white": "#000000", + # Semantic styles + "success": "bold #065f46", # Green 800 + "info": "#1e40af", # Blue 800 + "warning": "bold #9a3412", # Orange 800 + "error": "bold #991b1b", # Red 800 + # UI elements + "prompt": "#4c1d95", # Violet 900 + "prompt.choices": "#4c1d95", + "prompt.default": "#1f2937", + # Table styles + "table.header": "#000000", + "table.border": "#6b7280", # Grey 500 + "table.row": "#111827", # Grey 900 + # Progress/Loading + "progress.description": "#4c1d95", + "progress.percentage": "bold #4c1d95", + "bar.complete": "#5b21b6", # Violet 800 + "bar.finished": "#065f46", + "bar.pulse": "#9d174d", +} + + +def resolve_cli_theme( + env: dict[str, str] | None = None, +) -> CliThemeName: + """Pick light/dark CLI theme from env. + + Precedence: + 1. ``TG_THEME`` or ``TOGETHER_CLI_THEME`` = ``light`` | ``dark`` + 2. ``COLORFGBG`` background index (``>= 7`` → light) + 3. Default ``dark`` (historical CLI default) + """ + environ = os.environ if env is None else env + + for key in ("TG_THEME", "TOGETHER_CLI_THEME"): + explicit = environ.get(key, "").strip().lower() + if explicit in ("light", "dark"): + return explicit # type: ignore[return-value] + + colorfgbg = environ.get("COLORFGBG", "").strip() + if colorfgbg: + bg_part = colorfgbg.split(";")[-1].strip() + try: + bg = int(bg_part) + except ValueError: + pass + else: + # ANSI: 0 black … 7 white, 8–15 bright. Light terminals commonly use 7 or 15. + return "light" if bg >= 7 else "dark" + + return "dark" + + +def build_theme(theme_name: CliThemeName | None = None) -> Theme: + name = resolve_cli_theme() if theme_name is None else theme_name + styles = _LIGHT_STYLES if name == "light" else _DARK_STYLES + return Theme(styles) + + +def create_console(theme_name: CliThemeName | None = None) -> Console: + return Console(theme=build_theme(theme_name), highlight=False) + + +cli_theme_name: CliThemeName = resolve_cli_theme() +custom_theme = build_theme(cli_theme_name) +console = create_console(cli_theme_name) diff --git a/src/together/lib/cli/utils/_help_formatter.py b/src/together/lib/cli/utils/_help_formatter.py index dfb52eb2..45b4bb1b 100644 --- a/src/together/lib/cli/utils/_help_formatter.py +++ b/src/together/lib/cli/utils/_help_formatter.py @@ -100,7 +100,7 @@ def _description_renderer(entry: HelpEntry) -> Any: return description from rich.text import Text - suffix = Text(f" [default: {entry.default}]", style="dim") + suffix = Text(f" [default: {entry.default}]", style="muted") if description is None: return suffix if hasattr(description, "append"): @@ -128,7 +128,7 @@ def _description_renderer(entry: HelpEntry) -> Any: ), ColumnSpec( renderer=_description_renderer, - style="secondary", + style="muted", overflow="fold", ), ) diff --git a/src/together/lib/cli/utils/_prompt.py b/src/together/lib/cli/utils/_prompt.py index cc8935fe..10919e53 100644 --- a/src/together/lib/cli/utils/_prompt.py +++ b/src/together/lib/cli/utils/_prompt.py @@ -3,12 +3,12 @@ from typing import TYPE_CHECKING, Literal, cast from together.lib.cli.utils.config import CLIConfig -from together.lib.cli.utils._console import console +from together.lib.cli.utils._console import console, cli_theme_name if TYPE_CHECKING: pass -custom_style_fancy = [ +_DARK_PROMPT_STYLES = [ ("qmark", "fg:#caaef5 bold"), # token in front of the question ("question", "bold #caaef5"), # question text ("answer", "fg:#98a0b3 bold"), # submitted answer text behind the question @@ -21,6 +21,21 @@ ("disabled", "fg:#858585 italic"), # disabled choices for select and checkbox prompts ] +_LIGHT_PROMPT_STYLES = [ + ("qmark", "fg:#4c1d95 bold"), + ("question", "bold #4c1d95"), + ("answer", "fg:#111827 bold"), + ("pointer", "fg:#4c1d95 bold"), + ("highlighted", "fg:#4c1d95 bold"), + ("selected", "fg:#4c1d95"), + ("separator", "fg:#4c1d95"), + ("instruction", ""), + ("text", "#1f2937"), + ("disabled", "fg:#4b5563 italic"), +] + +custom_style_fancy = _LIGHT_PROMPT_STYLES if cli_theme_name == "light" else _DARK_PROMPT_STYLES + # class NameValidator(questionary.Validator): # def validate(self, document): diff --git a/tests/cli/test_console_theme.py b/tests/cli/test_console_theme.py new file mode 100644 index 00000000..21191706 --- /dev/null +++ b/tests/cli/test_console_theme.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +from rich.console import Console + +from together.lib.cli.utils._console import ( + _DARK_STYLES, + _LIGHT_STYLES, + build_theme, + resolve_cli_theme, +) + + +class TestResolveCliTheme: + def test_explicit_tg_theme_light(self) -> None: + assert resolve_cli_theme({"TG_THEME": "light"}) == "light" + + def test_explicit_tg_theme_dark(self) -> None: + assert resolve_cli_theme({"TG_THEME": "dark"}) == "dark" + + def test_explicit_together_cli_theme(self) -> None: + assert resolve_cli_theme({"TOGETHER_CLI_THEME": "light"}) == "light" + + def test_tg_theme_wins_over_colorfgbg(self) -> None: + assert resolve_cli_theme({"TG_THEME": "dark", "COLORFGBG": "0;15"}) == "dark" + + def test_colorfgbg_light_background(self) -> None: + assert resolve_cli_theme({"COLORFGBG": "0;15"}) == "light" + assert resolve_cli_theme({"COLORFGBG": "15;7"}) == "light" + + def test_colorfgbg_dark_background(self) -> None: + assert resolve_cli_theme({"COLORFGBG": "15;0"}) == "dark" + assert resolve_cli_theme({"COLORFGBG": "7;0"}) == "dark" + + def test_default_is_dark(self) -> None: + assert resolve_cli_theme({}) == "dark" + + def test_invalid_colorfgbg_falls_back_to_dark(self) -> None: + assert resolve_cli_theme({"COLORFGBG": "default;default"}) == "dark" + + +class TestBuildThemeContrast: + def test_light_theme_overrides_dim_and_white(self) -> None: + theme = build_theme("light") + assert "dim" in theme.styles + assert "white" in theme.styles + assert theme.styles["dim"].color is not None + assert theme.styles["white"].color is not None + # Must be solid colors, not ANSI dim / bright-white + assert not theme.styles["dim"].dim + assert theme.styles["dim"].color.name == _LIGHT_STYLES["dim"] + assert theme.styles["white"].color.name == _LIGHT_STYLES["white"] + + def test_dark_theme_keeps_ansi_dim_behavior(self) -> None: + theme = build_theme("dark") + # Dark theme should not replace Rich's built-in dim/white with near-black + assert "dim" not in _DARK_STYLES + assert "white" not in _DARK_STYLES + assert theme.styles["primary"].color is not None + assert theme.styles["primary"].color.name == _DARK_STYLES["primary"] + + def test_dark_secondary_is_not_dimmed(self) -> None: + theme = build_theme("dark") + assert not theme.styles["secondary"].dim + + def test_light_theme_emits_dark_foreground_for_help_styles(self) -> None: + console = Console( + theme=build_theme("light"), + force_terminal=True, + color_system="truecolor", + record=True, + width=80, + ) + console.print("[primary]name[/primary]") + console.print("[secondary]type[/secondary]") + console.print("[muted]description[/muted]") + console.print("[dim]example[/dim]") + console.print("[white]value[/white]") + ansi = console.export_text(styles=True) + + # Light theme truecolor codes for the high-contrast palette + assert "38;2;76;29;149" in ansi # primary #4c1d95 + assert "38;2;17;24;39" in ansi # secondary #111827 + assert "38;2;31;41;55" in ansi # muted/dim #1f2937 + assert "38;2;0;0;0" in ansi # white override #000000 + # Must not use ANSI dim or standard white on light theme for these tags + assert "\x1b[2m" not in ansi + assert "\x1b[37m" not in ansi + + def test_light_theme_body_text_meets_aaa_contrast_on_white(self) -> None: + """Body styles should clear WCAG AAA (7:1) against white.""" + + def relative_luminance(hex_color: str) -> float: + raw = hex_color.lstrip("#") + channels = [int(raw[i : i + 2], 16) / 255 for i in (0, 2, 4)] + + def linearize(channel: float) -> float: + return channel / 12.92 if channel <= 0.03928 else ((channel + 0.055) / 1.055) ** 2.4 + + r, g, b = (linearize(c) for c in channels) + return 0.2126 * r + 0.7152 * g + 0.0722 * b + + def contrast_on_white(hex_color: str) -> float: + fg = relative_luminance(hex_color) + bg = 1.0 + lighter, darker = max(fg, bg), min(fg, bg) + return (lighter + 0.05) / (darker + 0.05) + + for key in ("primary", "secondary", "muted", "dim", "white"): + style = _LIGHT_STYLES[key] + hex_color = style.split()[-1] # allow "bold #rrggbb" + ratio = contrast_on_white(hex_color) + assert ratio >= 7.0, f"{key}={style} contrast {ratio:.2f} < 7"