Windows got sixteen million colours, everyone else got 256

prompt_toolkit picks the colour depth per output class, and its two classes
disagree: Windows10_Output returns TRUE_COLOR outright, Vt100_Output returns
DEPTH_8_BIT for every TERM but linux. Nothing ever passed color_depth, so the
same build drawing the same theme was 24-bit on Windows and quantized to the
216-colour cube on macOS and Linux — silently, which is why it went unnoticed:
the app looks fine, just flatter.

It costs exactly what this palette is made of. The seven heat.* steps and the
per-node hue wheel are close pastels chosen to be told apart; snapped to the
cube, neighbours collide and an identity stops being distinguishable by hue.
COLORTERM is not consulted by prompt_toolkit at all — only its own
PROMPT_TOOLKIT_COLOR_DEPTH is — so a terminal announcing truecolor the
conventional way was still handed 256.

The resolver only ever raises the verdict. A terminal that cannot be shown to
do better returns None and keeps precisely the depth it had, so a host this has
never heard of cannot be made worse by it: guessing 24-bit at a terminal
without it costs not a duller palette but the colour entirely. A 16-slot
console is never promoted — the theme addresses its palette by index, and RGB
has nowhere to land there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5 committed 2026-09-08 18:35:32 -04:00
1 parent 4d8c67358c
commit c008aa5f01
3 files changed
+203

No files matched your search

+28
View File
@@ -126,6 +126,20 @@ _SILENCE_CHOICES: dict[int, str] = {
#: need to disagree with its platform.
_WIDTH_CHOICES: dict[str, str] = {"auto": "auto", "yes": "yes", "no": "no"}
#: How many colours to send the terminal. ``auto`` is the honest default and the only
#: value that is not itself a depth: it means "take prompt_toolkit's verdict unless we can
#: positively establish better", which is the difference between fixing a host and breaking
#: one. The rest are named for the count a reader can see rather than for the bit depth,
#: because "24-bit" is a fact about the wire and "16 million" is a fact about the screen.
#: See :func:`meshterm.ui.tui.session._color_depth` for why auto is not simply "the most
#: this terminal can do".
_COLOR_DEPTH_CHOICES: dict[str, str] = {
"auto": "auto",
"truecolor": "16 million",
"256": "256",
"16": "16",
}
#: Every preference MeshTerm has, in page order. Adding one here gives it a row on the
#: Preferences page, a key in the YAML file, a ``preferences get``/``set`` CLI face, and a
#: default — nothing else has to follow.
@@ -319,6 +333,20 @@ PREFERENCES: tuple[PrefSpec, ...] = (
default="auto",
choices=_WIDTH_CHOICES,
),
PrefSpec(
key="color_depth",
label="Colours",
help="How many colours to send this terminal",
group="Display",
value_type="enum",
# Auto, because the count a terminal accepts is a fact about the terminal and the
# reader should not have to know it. It is only ever consulted to *raise* the
# verdict prompt_toolkit already reached, never to lower it — a terminal that
# cannot be shown to do better keeps exactly what it had.
default="auto",
choices=_COLOR_DEPTH_CHOICES,
relaunch=True,
),
PrefSpec(
key="console_setup",
label="Console setup",
+68
View File
@@ -25,6 +25,7 @@ from prompt_toolkit.formatted_text import ANSI
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.keys import Keys
from prompt_toolkit.layout import ConditionalContainer, Float, FloatContainer, Layout, Window
from prompt_toolkit.output.color_depth import ColorDepth
from prompt_toolkit.utils import get_cwidth
from rich.console import RenderableType
from rich.text import Text
@@ -252,6 +253,70 @@ def _reclaim_last_column() -> bool:
return get_platform().width_reclaim
#: What each ``color_depth`` preference value asks for. ``auto`` is deliberately absent: it
#: is the one answer that is not a depth, and :func:`_color_depth` resolves it separately.
_DEPTH_BY_NAME = {
"truecolor": ColorDepth.DEPTH_24_BIT,
"256": ColorDepth.DEPTH_8_BIT,
"16": ColorDepth.DEPTH_4_BIT,
}
#: What ``COLORTERM`` says on a terminal that means it about 24-bit colour. There is no
#: in-band way to ask — a terminal that cannot do truecolor answers a truecolor SGR by
#: quietly approximating it, which looks from here exactly like one that can — so this
#: convention, which every truecolor terminal follows, is the whole of the evidence.
_TRUECOLOR_COLORTERM = frozenset({"truecolor", "24bit"})
def _color_depth() -> ColorDepth | None:
"""How many colours to send this terminal, or ``None`` to keep prompt_toolkit's verdict.
prompt_toolkit picks a depth per *output class*, and its two classes disagree:
``Windows10_Output`` returns ``TRUE_COLOR`` outright, while ``Vt100_Output`` returns
``DEPTH_8_BIT`` for every ``TERM`` but ``linux`` and a dumb terminal. So the same build
of MeshTerm, drawing the same theme, is 24-bit on Windows and 256 colours on macOS and
Linux — and it is quantized *silently*, which is why this went unnoticed: the app looks
fine, just flatter. It costs exactly what this palette is made of. The seven ``heat.*``
steps and the per-node hue wheel (:func:`~meshterm.ui.theme.node_style`) are close
pastels chosen to be told apart; snapped to the 216-colour cube, neighbours collide and
identities stop being distinguishable by hue. ``COLORTERM`` is not consulted by
prompt_toolkit at all — only ``PROMPT_TOOLKIT_COLOR_DEPTH`` is — so a terminal
announcing truecolor in the conventional way is still handed 256.
Three sources, in confidence order, the same shape as :func:`_reclaim_last_column`:
``MESHTERM_COLOR_DEPTH`` for a one-off, then the ``color_depth`` preference, then a
reading of the environment.
The reading only ever *raises* the verdict, never lowers it. A terminal we cannot get a
positive claim from returns ``None`` and keeps precisely the depth it had, so a host
this function has never heard of cannot be made worse by it — which matters more than
being right everywhere, because the failure mode of guessing 24-bit at a terminal
without it is not a duller palette but no colour at all.
Returns:
The depth to force, or ``None`` to leave prompt_toolkit's own default in place.
"""
from ...core.preferences import current as current_preferences
override = os.environ.get("MESHTERM_COLOR_DEPTH")
preferred = override if override is not None else current_preferences().color_depth
if preferred in _DEPTH_BY_NAME:
return _DEPTH_BY_NAME[preferred]
if preferred != "auto":
return None
# A 16-slot console is already right and has no truecolor to claim: the theme addresses
# its palette by index (see theme._VT_SLOTS), and promoting the depth would send RGB to
# a screen with sixteen colours to put it in.
if not get_platform().truecolor:
return None
if os.environ.get("COLORTERM", "").strip().lower() in _TRUECOLOR_COLORTERM:
return ColorDepth.DEPTH_24_BIT
# terminfo's own spelling for a direct-colour entry (``xterm-direct``, ``*-direct16m``).
if os.environ.get("TERM", "").endswith(("-direct", "-direct16m")):
return ColorDepth.DEPTH_24_BIT
return None
#: How many stacked dialog layers the layout can float over the background at once. A fixed
#: pool of centered-box floats (see :meth:`TuiSession._build_app`), sized well past the deepest
#: real nesting — a tool's list, an item's detail popup, and a confirm over that is only three.
@@ -1445,6 +1510,9 @@ class TuiSession:
max_render_postpone_time=None,
input=self._input,
output=self._resolve_output(),
# None leaves the output's own default alone; see _color_depth for why the
# two prompt_toolkit outputs disagree and why only a raise is ever applied.
color_depth=_color_depth(),
)
if fastrender.enabled():
# Swap in the row-diff renderer for plain full-screen frames. Built with the
+107
View File
@@ -0,0 +1,107 @@
"""Session colour tests: how many colours the terminal is actually sent.
prompt_toolkit decides this per *output class*, and its two classes disagree —
``Windows10_Output`` returns true colour outright, ``Vt100_Output`` returns 256 for every
``TERM`` but ``linux`` — so the same theme was drawn at 24 bits on Windows and quantized to
the 216-colour cube on macOS and Linux, silently. The resolver closes that gap, and these
tests pin the one property that makes it safe to run everywhere: it only ever *raises* the
verdict. A terminal that cannot be shown to do better returns ``None`` and keeps exactly the
depth prompt_toolkit gave it, because guessing 24-bit at a terminal without it costs not a
duller palette but the colour entirely.
"""
from __future__ import annotations
from prompt_toolkit.output.color_depth import ColorDepth
from meshterm.platforms import PICOCALC, REGULAR, set_platform
from meshterm.ui.tui.session import _color_depth
def _auto(monkeypatch) -> None:
"""Clear both overrides so the environment reading is what is under test."""
monkeypatch.delenv("MESHTERM_COLOR_DEPTH", raising=False)
monkeypatch.delenv("COLORTERM", raising=False)
monkeypatch.delenv("TERM", raising=False)
def test_an_unremarkable_terminal_keeps_prompt_toolkits_own_verdict(monkeypatch) -> None:
"""Nothing claimed → ``None``, so a host we have never heard of is never made worse."""
_auto(monkeypatch)
set_platform(REGULAR)
assert _color_depth() is None
def test_colorterm_truecolor_raises_the_depth(monkeypatch) -> None:
"""The convention every truecolor terminal follows — and the one prompt_toolkit ignores."""
_auto(monkeypatch)
monkeypatch.setenv("COLORTERM", "truecolor")
set_platform(REGULAR)
assert _color_depth() is ColorDepth.DEPTH_24_BIT
def test_colorterm_24bit_is_the_same_claim(monkeypatch) -> None:
"""``24bit`` is the other spelling in circulation; case is not part of the claim."""
_auto(monkeypatch)
monkeypatch.setenv("COLORTERM", "24BIT")
set_platform(REGULAR)
assert _color_depth() is ColorDepth.DEPTH_24_BIT
def test_a_direct_colour_terminfo_entry_raises_the_depth(monkeypatch) -> None:
"""``*-direct`` is terminfo's own spelling for a direct-colour terminal."""
_auto(monkeypatch)
monkeypatch.setenv("TERM", "xterm-direct")
set_platform(REGULAR)
assert _color_depth() is ColorDepth.DEPTH_24_BIT
def test_picocalc_is_never_promoted(monkeypatch) -> None:
"""A 16-slot console addresses its palette by index; RGB has nowhere to land there."""
_auto(monkeypatch)
monkeypatch.setenv("COLORTERM", "truecolor")
set_platform(PICOCALC)
assert _color_depth() is None
def test_env_override_names_a_depth_outright(monkeypatch) -> None:
"""``MESHTERM_COLOR_DEPTH`` wins over the reading, in either direction."""
_auto(monkeypatch)
monkeypatch.setenv("COLORTERM", "truecolor")
set_platform(REGULAR)
monkeypatch.setenv("MESHTERM_COLOR_DEPTH", "256")
assert _color_depth() is ColorDepth.DEPTH_8_BIT
monkeypatch.setenv("MESHTERM_COLOR_DEPTH", "16")
assert _color_depth() is ColorDepth.DEPTH_4_BIT
def test_the_preference_is_consulted_below_the_env_override(monkeypatch) -> None:
"""A stored preference names a depth the same way; ``auto`` falls through to the reading."""
from types import SimpleNamespace
_auto(monkeypatch)
set_platform(REGULAR)
monkeypatch.setattr(
"meshterm.core.preferences.current",
lambda: SimpleNamespace(color_depth="truecolor"),
)
assert _color_depth() is ColorDepth.DEPTH_24_BIT
monkeypatch.setattr(
"meshterm.core.preferences.current",
lambda: SimpleNamespace(color_depth="auto"),
)
assert _color_depth() is None
def test_an_unrecognised_preference_value_changes_nothing(monkeypatch) -> None:
"""A hand-edited YAML value we cannot read is not grounds for guessing."""
from types import SimpleNamespace
_auto(monkeypatch)
monkeypatch.setenv("COLORTERM", "truecolor")
set_platform(REGULAR)
monkeypatch.setattr(
"meshterm.core.preferences.current",
lambda: SimpleNamespace(color_depth="lots"),
)
assert _color_depth() is None