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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 114 additions & 30 deletions src/together/lib/cli/utils/_console.py
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 2 additions & 2 deletions src/together/lib/cli/utils/_help_formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down Expand Up @@ -128,7 +128,7 @@ def _description_renderer(entry: HelpEntry) -> Any:
),
ColumnSpec(
renderer=_description_renderer,
style="secondary",
style="muted",
overflow="fold",
),
)
Expand Down
19 changes: 17 additions & 2 deletions src/together/lib/cli/utils/_prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand Down
112 changes: 112 additions & 0 deletions tests/cli/test_console_theme.py
Original file line number Diff line number Diff line change
@@ -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"
Loading