feat(ui-tui): background-aware theme adaptation + paired palettes + /theme pin

OSC-11 asks the terminal for its actual background at startup (env
heuristics are blind on xterm.js hosts) and the theme re-derives against
the answer: desktop-contract adaptation (contrast floors + fill polarity),
a shared list-row selection primitive instead of per-picker panel fills,
paired light_colors/dark_colors skin blocks with a machine audit, and a
/theme auto|light|dark pin (display.tui_theme) for hosts whose probe lies.
E2E coverage for the OSC reply chain + /theme-info diagnostics.
This commit is contained in:
Brooklyn Nicholson 2026-07-20 17:36:18 -05:00
parent 4a129af709
commit cd05498e2c
25 changed files with 1467 additions and 552 deletions

View file

@ -48,6 +48,16 @@ All fields are optional. Missing values inherit from the ``default`` skin.
completion_menu_meta_bg: "#1a1a2e" # Completion meta column background
completion_menu_meta_current_bg: "#333355" # Active completion meta background
# Optional paired palette for the opposite terminal polarity (mirrors the
# desktop app's colors/darkColors pairing). If `colors` above is authored
# for dark terminals, `light_colors` supplies the hand-tuned light-terminal
# variant (same keys); light-authored skins supply `dark_colors` instead.
# Without a paired block, the TUI adapts `colors` automatically
# (contrast-clamped foregrounds, polarity-corrected fills).
light_colors:
banner_title: "#8B6914"
# ... same keys as `colors` ...
# Spinner: customize the animated spinner during API calls
spinner:
waiting_faces: # Faces shown while waiting for API
@ -132,6 +142,14 @@ class SkinConfig:
name: str
description: str = ""
colors: Dict[str, str] = field(default_factory=dict)
# Paired palettes for terminals whose background polarity differs from the
# one `colors` was authored against (mirrors the desktop app's
# colors/darkColors pairing). A consumer that knows the terminal is light
# prefers `light_colors` (falling back to `colors`), and vice versa for
# `dark_colors`. Both merge over the default skin's matching block, so
# partial user skins still resolve to a complete palette.
light_colors: Dict[str, str] = field(default_factory=dict)
dark_colors: Dict[str, str] = field(default_factory=dict)
spinner: Dict[str, Any] = field(default_factory=dict)
branding: Dict[str, str] = field(default_factory=dict)
tool_prefix: str = "┊"
@ -165,6 +183,8 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
"default": {
"name": "default",
"description": "Classic Hermes — gold and kawaii",
# Dark-authored. Values match the TUI's DARK_THEME so the classic CLI
# and the TUI render the same Hermes gold.
"colors": {
"banner_border": "#CD7F32",
"banner_title": "#FFD700",
@ -180,8 +200,51 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
"input_rule": "#CD7F32",
"response_border": "#FFD700",
"status_bar_bg": "#1a1a2e",
"status_bar_text": "#C0C0C0",
"status_bar_strong": "#FFD700",
"status_bar_dim": "#8A7A4A",
"status_bar_good": "#8FBC8F",
"status_bar_warn": "#FFD700",
"status_bar_bad": "#FF8C00",
"status_bar_critical": "#FF6B6B",
"session_label": "#DAA520",
"session_border": "#8B8682",
"completion_menu_bg": "#1a1a2e",
"completion_menu_current_bg": "#333355",
"selection_bg": "#3a3a55",
"shell_dollar": "#4dabf7",
"voice_status_bg": "#1a1a2e",
},
# Hand-tuned light palette (mirrors the TUI's LIGHT_THEME golds).
"light_colors": {
"banner_border": "#7A4F1F",
"banner_title": "#8B6914",
"banner_accent": "#A0651C",
"banner_dim": "#7A5A0F",
"banner_text": "#3D2F13",
"ui_accent": "#A0651C",
"ui_label": "#7A5A0F",
"ui_ok": "#2E7D32",
"ui_error": "#C62828",
"ui_warn": "#E65100",
"prompt": "#2B2014",
"input_rule": "#7A4F1F",
"response_border": "#8B6914",
"status_bar_bg": "#F5F5F5",
"status_bar_text": "#333333",
"status_bar_strong": "#8B6914",
"status_bar_dim": "#8A8A8A",
"status_bar_good": "#2E7D32",
"status_bar_warn": "#8B6914",
"status_bar_bad": "#D84315",
"status_bar_critical": "#B71C1C",
"session_label": "#7A5A0F",
"session_border": "#7A5A0F",
"completion_menu_bg": "#F5F5F5",
"completion_menu_current_bg": "#E0D1BF",
"selection_bg": "#D4E4F7",
"shell_dollar": "#1565C0",
"voice_status_bg": "#F5F5F5",
},
"spinner": {
# Empty = use hardcoded defaults in display.py
@ -200,10 +263,10 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
"name": "ares",
"description": "War-god theme — crimson and bronze",
"colors": {
"banner_border": "#9F1C1C",
"banner_border": "#A93333",
"banner_title": "#C7A96B",
"banner_accent": "#DD4A3A",
"banner_dim": "#6B1717",
"banner_dim": "#905151",
"banner_text": "#F1E6CF",
"ui_accent": "#DD4A3A",
"ui_label": "#C7A96B",
@ -211,18 +274,53 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
"ui_error": "#ef5350",
"ui_warn": "#ffa726",
"prompt": "#F1E6CF",
"input_rule": "#9F1C1C",
"input_rule": "#A93333",
"response_border": "#C7A96B",
"status_bar_bg": "#2A1212",
"status_bar_text": "#F1E6CF",
"status_bar_strong": "#C7A96B",
"status_bar_dim": "#6E584B",
"status_bar_dim": "#756054",
"status_bar_good": "#7BC96F",
"status_bar_warn": "#C7A96B",
"status_bar_bad": "#DD4A3A",
"status_bar_critical": "#EF5350",
"session_label": "#C7A96B",
"session_border": "#6E584B",
"completion_menu_bg": "#2A1212",
"completion_menu_current_bg": "#5C221D",
"selection_bg": "#692620",
"shell_dollar": "#DD4A3A",
"voice_status_bg": "#2A1212",
},
"light_colors": {
"banner_border": "#A93333",
"banner_title": "#8B764B",
"banner_accent": "#D24637",
"banner_dim": "#905151",
"banner_text": "#3C3A34",
"ui_accent": "#D24637",
"ui_label": "#8B764B",
"ui_ok": "#39833C",
"ui_error": "#CB4744",
"ui_warn": "#BF7D1C",
"prompt": "#3C3A34",
"input_rule": "#A93333",
"response_border": "#9F8756",
"status_bar_bg": "#F5EEEF",
"status_bar_text": "#33373D",
"status_bar_strong": "#847047",
"status_bar_dim": "#8A8F98",
"status_bar_good": "#508348",
"status_bar_warn": "#9F8756",
"status_bar_bad": "#D24637",
"status_bar_critical": "#CB4744",
"session_label": "#9F8756",
"session_border": "#6E584B",
"completion_menu_bg": "#F5EEEF",
"completion_menu_current_bg": "#F0CAC7",
"selection_bg": "#F8DBD8",
"shell_dollar": "#D24637",
"voice_status_bg": "#F5EEEF",
},
"spinner": {
"waiting_faces": ["(⚔)", "(⛨)", "(▲)", "(<>)", "(/)"],
@ -272,10 +370,10 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
"name": "mono",
"description": "Monochrome — clean grayscale",
"colors": {
"banner_border": "#555555",
"banner_border": "#5E5E5E",
"banner_title": "#e6edf3",
"banner_accent": "#aaaaaa",
"banner_dim": "#444444",
"banner_dim": "#606060",
"banner_text": "#c9d1d9",
"ui_accent": "#aaaaaa",
"ui_label": "#888888",
@ -283,7 +381,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
"ui_error": "#cccccc",
"ui_warn": "#999999",
"prompt": "#c9d1d9",
"input_rule": "#444444",
"input_rule": "#606060",
"response_border": "#aaaaaa",
"status_bar_bg": "#1F1F1F",
"status_bar_text": "#C9D1D9",
@ -294,7 +392,42 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
"status_bar_bad": "#D0D0D0",
"status_bar_critical": "#F0F0F0",
"session_label": "#888888",
"session_border": "#555555",
"session_border": "#5E5E5E",
"completion_menu_bg": "#1F1F1F",
"completion_menu_current_bg": "#464646",
"selection_bg": "#505050",
"shell_dollar": "#aaaaaa",
"voice_status_bg": "#1F1F1F",
},
"light_colors": {
"banner_border": "#5E5E5E",
"banner_title": "#73767A",
"banner_accent": "#777777",
"banner_dim": "#606060",
"banner_text": "#323436",
"ui_accent": "#777777",
"ui_label": "#7A7A7A",
"ui_ok": "#7A7A7A",
"ui_error": "#7A7A7A",
"ui_warn": "#919191",
"prompt": "#323436",
"input_rule": "#606060",
"response_border": "#909090",
"status_bar_bg": "#F2F3F5",
"status_bar_text": "#33373D",
"status_bar_strong": "#73767A",
"status_bar_dim": "#8A8F98",
"status_bar_good": "#767676",
"status_bar_warn": "#909090",
"status_bar_bad": "#727272",
"status_bar_critical": "#787878",
"session_label": "#888888",
"session_border": "#5E5E5E",
"completion_menu_bg": "#F2F3F5",
"completion_menu_current_bg": "#E2E3E4",
"selection_bg": "#EEEEEE",
"shell_dollar": "#777777",
"voice_status_bg": "#F2F3F5",
},
"spinner": {},
"branding": {
@ -314,7 +447,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
"banner_border": "#4169e1",
"banner_title": "#7eb8f6",
"banner_accent": "#8EA8FF",
"banner_dim": "#4b5563",
"banner_dim": "#545E6B",
"banner_text": "#c9d1d9",
"ui_accent": "#7eb8f6",
"ui_label": "#8EA8FF",
@ -327,13 +460,48 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
"status_bar_bg": "#151C2F",
"status_bar_text": "#C9D1D9",
"status_bar_strong": "#7EB8F6",
"status_bar_dim": "#4B5563",
"status_bar_dim": "#5D6672",
"status_bar_good": "#63D0A6",
"status_bar_warn": "#E6A855",
"status_bar_bad": "#F7A072",
"status_bar_critical": "#FF7A7A",
"session_label": "#7eb8f6",
"session_border": "#4b5563",
"session_border": "#545E6B",
"completion_menu_bg": "#151C2F",
"completion_menu_current_bg": "#324867",
"selection_bg": "#3A5375",
"shell_dollar": "#7eb8f6",
"voice_status_bg": "#151C2F",
},
"light_colors": {
"banner_border": "#4169e1",
"banner_title": "#5278A0",
"banner_accent": "#6376B2",
"banner_dim": "#545E6B",
"banner_text": "#323436",
"ui_accent": "#5278A0",
"ui_label": "#6376B2",
"ui_ok": "#40876C",
"ui_error": "#A1684A",
"ui_warn": "#B88644",
"prompt": "#323436",
"input_rule": "#4169e1",
"response_border": "#6593C5",
"status_bar_bg": "#F0F4F9",
"status_bar_text": "#33373D",
"status_bar_strong": "#5278A0",
"status_bar_dim": "#8A8F98",
"status_bar_good": "#40876C",
"status_bar_warn": "#B88644",
"status_bar_bad": "#A1684A",
"status_bar_critical": "#BF5C5C",
"session_label": "#6593C5",
"session_border": "#545E6B",
"completion_menu_bg": "#F0F4F9",
"completion_menu_current_bg": "#C3DAF3",
"selection_bg": "#E5F1FD",
"shell_dollar": "#5278A0",
"voice_status_bg": "#F0F4F9",
},
"spinner": {},
"branding": {
@ -361,16 +529,55 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
"ui_error": "#B91C1C",
"ui_warn": "#B45309",
"prompt": "#111827",
"input_rule": "#93C5FD",
"input_rule": "#6E94BE",
"response_border": "#2563EB",
"status_bar_bg": "#E5EDF8",
"status_bar_text": "#111827",
"status_bar_strong": "#2563EB",
"status_bar_dim": "#838890",
"status_bar_good": "#15803D",
"status_bar_warn": "#B45309",
"status_bar_bad": "#B45309",
"status_bar_critical": "#B91C1C",
"session_label": "#1D4ED8",
"session_border": "#64748B",
"status_bar_bg": "#E5EDF8",
"voice_status_bg": "#E5EDF8",
"completion_menu_bg": "#F8FAFC",
"completion_menu_current_bg": "#DBEAFE",
"completion_menu_meta_bg": "#EEF2FF",
"completion_menu_meta_current_bg": "#BFDBFE",
"selection_bg": "#D3E0FB",
"shell_dollar": "#2563EB",
"voice_status_bg": "#E5EDF8",
},
"dark_colors": {
"banner_border": "#2563EB",
"banner_title": "#767B85",
"banner_accent": "#4A71E0",
"banner_dim": "#596677",
"banner_text": "#DBDCDE",
"ui_accent": "#3A72ED",
"ui_label": "#328A83",
"ui_ok": "#2C8C50",
"ui_error": "#CA5252",
"ui_warn": "#B45309",
"prompt": "#DBDCDE",
"input_rule": "#6E94BE",
"response_border": "#2563EB",
"status_bar_bg": "#161C2A",
"status_bar_text": "#D8DADC",
"status_bar_strong": "#5C8BF2",
"status_bar_dim": "#838890",
"status_bar_good": "#2C8C50",
"status_bar_warn": "#B45309",
"status_bar_bad": "#BC6421",
"status_bar_critical": "#CA5252",
"session_label": "#3460DC",
"session_border": "#64748B",
"completion_menu_bg": "#161C2A",
"completion_menu_current_bg": "#1A2E5A",
"selection_bg": "#1A3060",
"shell_dollar": "#3A72ED",
"voice_status_bg": "#161C2A",
},
"spinner": {},
"branding": {
@ -400,14 +607,53 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
"prompt": "#2C1810",
"input_rule": "#8B6914",
"response_border": "#8B6914",
"status_bar_bg": "#F5F0E8",
"status_bar_text": "#2C1810",
"status_bar_strong": "#8B4513",
"status_bar_dim": "#8A8F98",
"status_bar_good": "#2E7D32",
"status_bar_warn": "#E65100",
"status_bar_bad": "#DA4D00",
"status_bar_critical": "#C62828",
"session_label": "#5C3D11",
"session_border": "#A0845C",
"status_bar_bg": "#F5F0E8",
"voice_status_bg": "#F5F0E8",
"completion_menu_bg": "#F5EFE0",
"completion_menu_current_bg": "#E8DCC8",
"completion_menu_meta_bg": "#F0E8D8",
"completion_menu_meta_current_bg": "#DFCFB0",
"selection_bg": "#E8DAD0",
"shell_dollar": "#8B4513",
"voice_status_bg": "#F5F0E8",
},
"dark_colors": {
"banner_border": "#8B6914",
"banner_title": "#8B7555",
"banner_accent": "#A7714B",
"banner_dim": "#8B7355",
"banner_text": "#DFDCDB",
"ui_accent": "#A7714B",
"ui_label": "#8B7555",
"ui_ok": "#428A46",
"ui_error": "#D15151",
"ui_warn": "#E65100",
"prompt": "#DFDCDB",
"input_rule": "#8B6914",
"response_border": "#8B6914",
"status_bar_bg": "#1C1B1D",
"status_bar_text": "#DAD6D5",
"status_bar_strong": "#A7714B",
"status_bar_dim": "#8A8F98",
"status_bar_good": "#428A46",
"status_bar_warn": "#E65100",
"status_bar_bad": "#DA4D00",
"status_bar_critical": "#D15151",
"session_label": "#7B623F",
"session_border": "#A0845C",
"completion_menu_bg": "#1C1B1D",
"completion_menu_current_bg": "#38261A",
"selection_bg": "#3B261A",
"shell_dollar": "#A7714B",
"voice_status_bg": "#1C1B1D",
},
"spinner": {},
"branding": {
@ -427,7 +673,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
"banner_border": "#2A6FB9",
"banner_title": "#A9DFFF",
"banner_accent": "#5DB8F5",
"banner_dim": "#153C73",
"banner_dim": "#44638F",
"banner_text": "#EAF7FF",
"ui_accent": "#5DB8F5",
"ui_label": "#A9DFFF",
@ -440,13 +686,48 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
"status_bar_bg": "#0F2440",
"status_bar_text": "#EAF7FF",
"status_bar_strong": "#A9DFFF",
"status_bar_dim": "#496884",
"status_bar_dim": "#52708A",
"status_bar_good": "#6ED7B0",
"status_bar_warn": "#5DB8F5",
"status_bar_bad": "#2A6FB9",
"status_bar_bad": "#3576BC",
"status_bar_critical": "#D94F4F",
"session_label": "#A9DFFF",
"session_border": "#496884",
"completion_menu_bg": "#0F2440",
"completion_menu_current_bg": "#254D73",
"selection_bg": "#2A587F",
"shell_dollar": "#5DB8F5",
"voice_status_bg": "#0F2440",
},
"light_colors": {
"banner_border": "#2A6FB9",
"banner_title": "#5D7B8C",
"banner_accent": "#3C789F",
"banner_dim": "#44638F",
"banner_text": "#3A3E40",
"ui_accent": "#3C789F",
"ui_label": "#5D7B8C",
"ui_ok": "#39833C",
"ui_error": "#CB4744",
"ui_warn": "#BF7D1C",
"prompt": "#3A3E40",
"input_rule": "#2A6FB9",
"response_border": "#4A93C4",
"status_bar_bg": "#EEF4F9",
"status_bar_text": "#33373D",
"status_bar_strong": "#5D7B8C",
"status_bar_dim": "#8A8F98",
"status_bar_good": "#42816A",
"status_bar_warn": "#4A93C4",
"status_bar_bad": "#3576BC",
"status_bar_critical": "#CE4B4B",
"session_label": "#6E91A6",
"session_border": "#496884",
"completion_menu_bg": "#EEF4F9",
"completion_menu_current_bg": "#CEE7F8",
"selection_bg": "#DFF1FD",
"shell_dollar": "#3C789F",
"voice_status_bg": "#EEF4F9",
},
"spinner": {
"waiting_faces": ["(≈)", "(Ψ)", "(∿)", "(◌)", "(◠)"],
@ -499,7 +780,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
"banner_border": "#B7B7B7",
"banner_title": "#F5F5F5",
"banner_accent": "#E7E7E7",
"banner_dim": "#4A4A4A",
"banner_dim": "#5C5C5C",
"banner_text": "#D3D3D3",
"ui_accent": "#E7E7E7",
"ui_label": "#D3D3D3",
@ -512,13 +793,48 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
"status_bar_bg": "#202020",
"status_bar_text": "#D3D3D3",
"status_bar_strong": "#F5F5F5",
"status_bar_dim": "#656565",
"status_bar_dim": "#6D6D6D",
"status_bar_good": "#B7B7B7",
"status_bar_warn": "#D3D3D3",
"status_bar_bad": "#E7E7E7",
"status_bar_critical": "#F5F5F5",
"session_label": "#919191",
"session_border": "#656565",
"completion_menu_bg": "#202020",
"completion_menu_current_bg": "#585858",
"selection_bg": "#666666",
"shell_dollar": "#E7E7E7",
"voice_status_bg": "#202020",
},
"light_colors": {
"banner_border": "#898989",
"banner_title": "#7A7A7A",
"banner_accent": "#747474",
"banner_dim": "#5C5C5C",
"banner_text": "#353535",
"ui_accent": "#747474",
"ui_label": "#747474",
"ui_ok": "#747474",
"ui_error": "#747474",
"ui_warn": "#898989",
"prompt": "#3D3D3D",
"input_rule": "#656565",
"response_border": "#898989",
"status_bar_bg": "#F5F6F8",
"status_bar_text": "#33373D",
"status_bar_strong": "#7A7A7A",
"status_bar_dim": "#8A8F98",
"status_bar_good": "#777777",
"status_bar_warn": "#898989",
"status_bar_bad": "#747474",
"status_bar_critical": "#7A7A7A",
"session_label": "#919191",
"session_border": "#656565",
"completion_menu_bg": "#F5F6F8",
"completion_menu_current_bg": "#D9DBDE",
"selection_bg": "#FAFAFA",
"shell_dollar": "#747474",
"voice_status_bg": "#F5F6F8",
},
"spinner": {
"waiting_faces": ["(◉)", "(◌)", "(◬)", "(⬤)", "(::)"],
@ -585,18 +901,50 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
"status_bar_bg": "#2B160E",
"status_bar_text": "#FFF0D4",
"status_bar_strong": "#FFD39A",
"status_bar_dim": "#6C4724",
"status_bar_dim": "#826144",
"status_bar_good": "#6BCB77",
"status_bar_warn": "#F29C38",
"status_bar_bad": "#E2832B",
"status_bar_critical": "#EF5350",
"session_label": "#FFD39A",
"session_border": "#6C4724",
"selection_bg": "#5A260D",
"session_border": "#7B593A",
"completion_menu_bg": "#0B0503",
"completion_menu_current_bg": "#4A1B07",
"completion_menu_meta_bg": "#120806",
"completion_menu_meta_current_bg": "#5A260D",
"selection_bg": "#5A260D",
"shell_dollar": "#F29C38",
"voice_status_bg": "#2B160E",
},
"light_colors": {
"banner_border": "#C75B1D",
"banner_title": "#8C7455",
"banner_accent": "#A96D27",
"banner_dim": "#BB8342",
"banner_text": "#403C35",
"ui_accent": "#A96D27",
"ui_label": "#8C7455",
"ui_ok": "#39833C",
"ui_error": "#CB4744",
"ui_warn": "#BF7D1C",
"prompt": "#403C35",
"input_rule": "#C75B1D",
"response_border": "#C27D2D",
"status_bar_bg": "#F6F2EF",
"status_bar_text": "#33373D",
"status_bar_strong": "#8C7455",
"status_bar_dim": "#8A8F98",
"status_bar_good": "#46844D",
"status_bar_warn": "#C27D2D",
"status_bar_bad": "#AA6220",
"status_bar_critical": "#CB4744",
"session_label": "#A68964",
"session_border": "#7B593A",
"completion_menu_bg": "#F6F2EF",
"completion_menu_current_bg": "#F5DFC7",
"selection_bg": "#FCEBD7",
"shell_dollar": "#A96D27",
"voice_status_bg": "#F6F2EF",
},
"spinner": {
"waiting_faces": ["(✦)", "(▲)", "(◇)", "(<>)", "(🔥)"],
@ -703,10 +1051,20 @@ def _build_skin_config(data: Dict[str, Any]) -> SkinConfig:
branding = dict(default.get("branding", {}))
branding.update(branding_overrides)
# Paired palettes are NOT merged over the default skin's blocks: an empty
# block means "this skin has no hand-tuned variant for that polarity", and
# consumers (the TUI) fall back to `colors` + automatic adaptation. Merging
# the default's gold light palette under a crimson skin would be worse
# than adapting the crimson.
light_colors = _mapping_or_empty(data.get("light_colors"), section="light_colors", skin_name=skin_name)
dark_colors = _mapping_or_empty(data.get("dark_colors"), section="dark_colors", skin_name=skin_name)
return SkinConfig(
name=skin_name,
description=data.get("description", ""),
colors=colors,
light_colors=light_colors,
dark_colors=dark_colors,
spinner=spinner,
branding=branding,
tool_prefix=data.get("tool_prefix", default.get("tool_prefix", "┊")),