Skip to content

Commit d0e5bdf

Browse files
fix(cli): make help/output colors legible on light terminals
Add light/dark Rich themes selected via TG_THEME/TOGETHER_CLI_THEME or COLORFGBG. Light theme uses darker brand tones and overrides dim/white so help text, examples, and pagination stay readable on white backgrounds. Co-authored-by: Blaine Kasten <blainekasten@gmail.com>
1 parent 2fb5e59 commit d0e5bdf

4 files changed

Lines changed: 221 additions & 34 deletions

File tree

Lines changed: 113 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,116 @@
1+
from __future__ import annotations
2+
3+
import os
4+
from typing import Literal
5+
16
from rich.theme import Theme
27
from rich.console import Console
38

4-
custom_theme = Theme(
5-
{
6-
# Text styles
7-
"primary": "#caaef5", # Purple 300 ⭐ (lighter when bold)
8-
"secondary": "dim #caaef5", # Purple 500 ⭐ (mid-tone without bold)
9-
"accent": "#ff68d4", # Pink 500 ⭐
10-
"muted": "#98a0b3", # Grey 400 ⭐
11-
# Semantic styles
12-
"success": "bold #0dce74", # Green 400 ⭐
13-
"info": "#64afff", # Blue 500 ⭐
14-
"warning": "bold #ff815d", # Red 500 ⭐
15-
"error": "bold #c63800", # Red 700 ⭐
16-
# UI elements
17-
"prompt": "#ba92ff", # Purple 500 ⭐ (no bold)
18-
"prompt.choices": "#caaef5", # Purple 300 ⭐
19-
"prompt.default": "dim #98a0b3", # Grey 400 ⭐
20-
# Table styles
21-
"table.header": "#414858", # Purple 300 ⭐ (lighter when bold)
22-
"table.border": "#626b84", # Grey 600 ⭐
23-
"table.row": "#c4c9d4", # Grey 300 ⭐
24-
# Progress/Loading
25-
"progress.description": "#caaef5", # Purple 300 ⭐
26-
"progress.percentage": "bold #caaef5", # Purple 300 ⭐ (lighter when bold)
27-
"bar.complete": "#ba92ff", # Purple 500 ⭐ (no bold)
28-
"bar.finished": "#0dce74", # Green 400 ⭐
29-
"bar.pulse": "#ff68d4", # Pink 500 ⭐
30-
}
31-
)
32-
33-
console = Console(theme=custom_theme, highlight=False)
9+
CliThemeName = Literal["light", "dark"]
10+
11+
# Dark theme: tuned for dark terminal backgrounds (original Together CLI palette).
12+
_DARK_STYLES = {
13+
# Text styles
14+
"primary": "#caaef5", # Purple 300
15+
"secondary": "#caaef5", # solid (no dim) so help body text stays readable
16+
"accent": "#ff68d4", # Pink 500
17+
"muted": "#98a0b3", # Grey 400
18+
# Semantic styles
19+
"success": "bold #0dce74", # Green 400
20+
"info": "#64afff", # Blue 500
21+
"warning": "bold #ff815d", # Red 500
22+
"error": "bold #c63800", # Red 700
23+
# UI elements
24+
"prompt": "#ba92ff", # Purple 500
25+
"prompt.choices": "#caaef5", # Purple 300
26+
"prompt.default": "#98a0b3", # Grey 400
27+
# Table styles
28+
"table.header": "#414858",
29+
"table.border": "#626b84", # Grey 600
30+
"table.row": "#c4c9d4", # Grey 300
31+
# Progress/Loading
32+
"progress.description": "#caaef5", # Purple 300
33+
"progress.percentage": "bold #caaef5",
34+
"bar.complete": "#ba92ff", # Purple 500
35+
"bar.finished": "#0dce74", # Green 400
36+
"bar.pulse": "#ff68d4", # Pink 500
37+
}
38+
39+
# Light theme: darker brand tones + solid dim/white overrides for white backgrounds.
40+
# `[dim]` / `[white]` markup resolves through the theme, so overrides fix widespread help
41+
# and pagination output without touching every call site.
42+
_LIGHT_STYLES = {
43+
# Text styles
44+
"primary": "#6d28d9", # Violet 700
45+
"secondary": "#5b21b6", # Violet 800
46+
"accent": "#db2777", # Pink 600
47+
"muted": "#4b5563", # Grey 600
48+
# Replace ANSI dim / bright-white (both fail on light backgrounds)
49+
"dim": "#4b5563", # Grey 600
50+
"white": "#111827", # Grey 900
51+
# Semantic styles
52+
"success": "bold #047857", # Green 700
53+
"info": "#1d4ed8", # Blue 700
54+
"warning": "bold #c2410c", # Orange 700
55+
"error": "bold #b91c1c", # Red 700
56+
# UI elements
57+
"prompt": "#6d28d9", # Violet 700
58+
"prompt.choices": "#6d28d9",
59+
"prompt.default": "#4b5563",
60+
# Table styles
61+
"table.header": "#111827", # Grey 900
62+
"table.border": "#9ca3af", # Grey 400
63+
"table.row": "#1f2937", # Grey 800
64+
# Progress/Loading
65+
"progress.description": "#6d28d9",
66+
"progress.percentage": "bold #6d28d9",
67+
"bar.complete": "#7c3aed", # Violet 600
68+
"bar.finished": "#047857",
69+
"bar.pulse": "#db2777",
70+
}
71+
72+
73+
def resolve_cli_theme(
74+
env: dict[str, str] | None = None,
75+
) -> CliThemeName:
76+
"""Pick light/dark CLI theme from env.
77+
78+
Precedence:
79+
1. ``TG_THEME`` or ``TOGETHER_CLI_THEME`` = ``light`` | ``dark``
80+
2. ``COLORFGBG`` background index (``>= 7`` → light)
81+
3. Default ``dark`` (historical CLI default)
82+
"""
83+
environ = os.environ if env is None else env
84+
85+
for key in ("TG_THEME", "TOGETHER_CLI_THEME"):
86+
explicit = environ.get(key, "").strip().lower()
87+
if explicit in ("light", "dark"):
88+
return explicit # type: ignore[return-value]
89+
90+
colorfgbg = environ.get("COLORFGBG", "").strip()
91+
if colorfgbg:
92+
bg_part = colorfgbg.split(";")[-1].strip()
93+
try:
94+
bg = int(bg_part)
95+
except ValueError:
96+
pass
97+
else:
98+
# ANSI: 0 black … 7 white, 8–15 bright. Light terminals commonly use 7 or 15.
99+
return "light" if bg >= 7 else "dark"
100+
101+
return "dark"
102+
103+
104+
def build_theme(theme_name: CliThemeName | None = None) -> Theme:
105+
name = resolve_cli_theme() if theme_name is None else theme_name
106+
styles = _LIGHT_STYLES if name == "light" else _DARK_STYLES
107+
return Theme(styles)
108+
109+
110+
def create_console(theme_name: CliThemeName | None = None) -> Console:
111+
return Console(theme=build_theme(theme_name), highlight=False)
112+
113+
114+
cli_theme_name: CliThemeName = resolve_cli_theme()
115+
custom_theme = build_theme(cli_theme_name)
116+
console = create_console(cli_theme_name)

src/together/lib/cli/utils/_help_formatter.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ def _description_renderer(entry: HelpEntry) -> Any:
100100
return description
101101
from rich.text import Text
102102

103-
suffix = Text(f" [default: {entry.default}]", style="dim")
103+
suffix = Text(f" [default: {entry.default}]", style="muted")
104104
if description is None:
105105
return suffix
106106
if hasattr(description, "append"):
@@ -128,7 +128,7 @@ def _description_renderer(entry: HelpEntry) -> Any:
128128
),
129129
ColumnSpec(
130130
renderer=_description_renderer,
131-
style="secondary",
131+
style="muted",
132132
overflow="fold",
133133
),
134134
)

src/together/lib/cli/utils/_prompt.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@
33
from typing import TYPE_CHECKING, Literal, cast
44

55
from together.lib.cli.utils.config import CLIConfig
6-
from together.lib.cli.utils._console import console
6+
from together.lib.cli.utils._console import console, cli_theme_name
77

88
if TYPE_CHECKING:
99
pass
1010

11-
custom_style_fancy = [
11+
_DARK_PROMPT_STYLES = [
1212
("qmark", "fg:#caaef5 bold"), # token in front of the question
1313
("question", "bold #caaef5"), # question text
1414
("answer", "fg:#98a0b3 bold"), # submitted answer text behind the question
@@ -21,6 +21,21 @@
2121
("disabled", "fg:#858585 italic"), # disabled choices for select and checkbox prompts
2222
]
2323

24+
_LIGHT_PROMPT_STYLES = [
25+
("qmark", "fg:#6d28d9 bold"),
26+
("question", "bold #6d28d9"),
27+
("answer", "fg:#4b5563 bold"),
28+
("pointer", "fg:#6d28d9 bold"),
29+
("highlighted", "fg:#6d28d9 bold"),
30+
("selected", "fg:#6d28d9"),
31+
("separator", "fg:#6d28d9"),
32+
("instruction", ""),
33+
("text", "#4b5563"),
34+
("disabled", "fg:#6b7280 italic"),
35+
]
36+
37+
custom_style_fancy = _LIGHT_PROMPT_STYLES if cli_theme_name == "light" else _DARK_PROMPT_STYLES
38+
2439

2540
# class NameValidator(questionary.Validator):
2641
# def validate(self, document):

tests/cli/test_console_theme.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
from __future__ import annotations
2+
3+
from rich.console import Console
4+
5+
from together.lib.cli.utils._console import (
6+
_DARK_STYLES,
7+
_LIGHT_STYLES,
8+
build_theme,
9+
resolve_cli_theme,
10+
)
11+
12+
13+
class TestResolveCliTheme:
14+
def test_explicit_tg_theme_light(self) -> None:
15+
assert resolve_cli_theme({"TG_THEME": "light"}) == "light"
16+
17+
def test_explicit_tg_theme_dark(self) -> None:
18+
assert resolve_cli_theme({"TG_THEME": "dark"}) == "dark"
19+
20+
def test_explicit_together_cli_theme(self) -> None:
21+
assert resolve_cli_theme({"TOGETHER_CLI_THEME": "light"}) == "light"
22+
23+
def test_tg_theme_wins_over_colorfgbg(self) -> None:
24+
assert (
25+
resolve_cli_theme({"TG_THEME": "dark", "COLORFGBG": "0;15"}) == "dark"
26+
)
27+
28+
def test_colorfgbg_light_background(self) -> None:
29+
assert resolve_cli_theme({"COLORFGBG": "0;15"}) == "light"
30+
assert resolve_cli_theme({"COLORFGBG": "15;7"}) == "light"
31+
32+
def test_colorfgbg_dark_background(self) -> None:
33+
assert resolve_cli_theme({"COLORFGBG": "15;0"}) == "dark"
34+
assert resolve_cli_theme({"COLORFGBG": "7;0"}) == "dark"
35+
36+
def test_default_is_dark(self) -> None:
37+
assert resolve_cli_theme({}) == "dark"
38+
39+
def test_invalid_colorfgbg_falls_back_to_dark(self) -> None:
40+
assert resolve_cli_theme({"COLORFGBG": "default;default"}) == "dark"
41+
42+
43+
class TestBuildThemeContrast:
44+
def test_light_theme_overrides_dim_and_white(self) -> None:
45+
theme = build_theme("light")
46+
assert "dim" in theme.styles
47+
assert "white" in theme.styles
48+
assert theme.styles["dim"].color is not None
49+
assert theme.styles["white"].color is not None
50+
# Must be solid colors, not ANSI dim / bright-white
51+
assert not theme.styles["dim"].dim
52+
assert theme.styles["dim"].color.name == _LIGHT_STYLES["dim"]
53+
assert theme.styles["white"].color.name == _LIGHT_STYLES["white"]
54+
55+
def test_dark_theme_keeps_ansi_dim_behavior(self) -> None:
56+
theme = build_theme("dark")
57+
# Dark theme should not replace Rich's built-in dim/white with near-black
58+
assert "dim" not in _DARK_STYLES
59+
assert "white" not in _DARK_STYLES
60+
assert theme.styles["primary"].color is not None
61+
assert theme.styles["primary"].color.name == _DARK_STYLES["primary"]
62+
63+
def test_dark_secondary_is_not_dimmed(self) -> None:
64+
theme = build_theme("dark")
65+
assert not theme.styles["secondary"].dim
66+
67+
def test_light_theme_emits_dark_foreground_for_help_styles(self) -> None:
68+
console = Console(
69+
theme=build_theme("light"),
70+
force_terminal=True,
71+
color_system="truecolor",
72+
record=True,
73+
width=80,
74+
)
75+
console.print("[primary]name[/primary]")
76+
console.print("[secondary]type[/secondary]")
77+
console.print("[muted]description[/muted]")
78+
console.print("[dim]example[/dim]")
79+
console.print("[white]value[/white]")
80+
ansi = console.export_text(styles=True)
81+
82+
# Light theme truecolor codes for the chosen palette
83+
assert "38;2;109;40;217" in ansi # primary #6d28d9
84+
assert "38;2;91;33;182" in ansi # secondary #5b21b6
85+
assert "38;2;75;85;99" in ansi # muted/dim #4b5563
86+
assert "38;2;17;24;39" in ansi # white override #111827
87+
# Must not use ANSI dim or standard white on light theme for these tags
88+
assert "\x1b[2m" not in ansi
89+
assert "\x1b[37m" not in ansi

0 commit comments

Comments
 (0)