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", "")),

442
package-lock.json generated
View file

@ -1927,448 +1927,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",

View file

@ -48,7 +48,12 @@ class TestBuiltinSkins:
skin = load_skin("ares")
assert skin.name == "ares"
assert skin.tool_prefix == ""
assert skin.get_color("banner_border") == "#9F1C1C"
# Crimson identity: border stays red-dominant (exact values are owned
# by the palette audit in test_skin_palettes.py, which enforces
# contrast floors — don't pin literals here).
border = skin.get_color("banner_border")
r, g, b = (int(border[i:i + 2], 16) for i in (1, 3, 5))
assert r > g and r > b, f"ares border lost its crimson: {border}"
assert skin.get_color("response_border") == "#C7A96B"
assert skin.get_color("session_label") == "#C7A96B"
assert skin.get_color("session_border") == "#6E584B"

View file

@ -0,0 +1,196 @@
"""Built-in skin palette audit: completeness + WCAG contrast, per polarity.
Every built-in skin must be a complete, coherent palette with no accidental
fallbacks (a partial skin inherits the default skin's gold, which is how
"slate feels all over the place" happened), and every palette must be a
fixed point of the TUI's runtime readability adaptation — hand-tuned values
that already pass the same contrast floors the TUI enforces (strong >= 3.9,
soft >= 2.8, fills matching the background polarity). Mirrors the desktop
app's paired colors/darkColors contract.
"""
import pytest
from hermes_cli.skin_engine import _BUILTIN_SKINS
# Union of the color keys consumed by the TUI (fromSkin) and the classic CLI
# (banner.py / display.py / prompt_toolkit overrides). completion_menu_meta_*
# intentionally excluded: they default to the base menu keys.
REQUIRED_KEYS = frozenset(
{
"banner_border",
"banner_title",
"banner_accent",
"banner_dim",
"banner_text",
"ui_accent",
"ui_label",
"ui_ok",
"ui_error",
"ui_warn",
"prompt",
"input_rule",
"response_border",
"status_bar_bg",
"status_bar_text",
"status_bar_strong",
"status_bar_dim",
"status_bar_good",
"status_bar_warn",
"status_bar_bad",
"status_bar_critical",
"session_label",
"session_border",
"completion_menu_bg",
"completion_menu_current_bg",
"selection_bg",
"shell_dollar",
"voice_status_bg",
}
)
# Foreground roles and their minimum contrast against the palette's pole.
# Matches ui-tui/src/theme.ts STRONG/SOFT tiers.
STRONG_FG = (
"banner_title",
"banner_accent",
"banner_text",
"ui_accent",
"ui_label",
"ui_ok",
"ui_error",
"prompt",
"status_bar_strong",
"status_bar_good",
"status_bar_bad",
"status_bar_critical",
"shell_dollar",
)
SOFT_FG = (
"banner_dim",
"banner_border",
"ui_warn",
"input_rule",
"response_border",
"status_bar_dim",
"status_bar_warn",
"session_label",
"session_border",
)
# status_bar_text renders on status_bar_bg, not the terminal background.
ON_STATUS_BAR = ("status_bar_text", "status_bar_strong", "status_bar_dim")
FILLS = (
"status_bar_bg",
"completion_menu_bg",
"completion_menu_current_bg",
"selection_bg",
"voice_status_bg",
)
STRONG_MIN = 3.9
SOFT_MIN = 2.8
# Assumed terminal poles, matching ui-tui/src/theme.ts referenceBackground().
DARK_POLE = "#101014"
LIGHT_POLE = "#ffffff"
def _rgb(hex_color: str):
h = hex_color.lstrip("#")
assert len(h) == 6, f"not a 6-digit hex: {hex_color!r}"
return tuple(int(h[i : i + 2], 16) for i in (0, 2, 4))
def _channel(v: float) -> float:
c = v / 255
return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
def luminance(hex_color: str) -> float:
r, g, b = _rgb(hex_color)
return 0.2126 * _channel(r) + 0.7152 * _channel(g) + 0.0722 * _channel(b)
def contrast(a: str, b: str) -> float:
la, lb = luminance(a), luminance(b)
hi, lo = max(la, lb), min(la, lb)
return (hi + 0.05) / (lo + 0.05)
def _palettes():
"""Yield (skin, palette_name, palette, is_light) for every built-in."""
for name, skin in _BUILTIN_SKINS.items():
colors = skin.get("colors", {})
light = skin.get("light_colors", {})
dark = skin.get("dark_colors", {})
# `colors` polarity is declared by which paired block the skin ships.
colors_are_light = bool(dark) and not light
yield name, "colors", colors, colors_are_light
if light:
yield name, "light_colors", light, True
if dark:
yield name, "dark_colors", dark, False
ALL_PALETTES = list(_palettes())
PALETTE_IDS = [f"{skin}:{block}" for skin, block, _, _ in ALL_PALETTES]
def test_every_builtin_ships_a_paired_palette():
for name, skin in _BUILTIN_SKINS.items():
assert skin.get("light_colors") or skin.get("dark_colors"), (
f"skin {name!r} has no paired palette: dark-authored skins need "
f"light_colors, light-authored skins need dark_colors"
)
assert not (skin.get("light_colors") and skin.get("dark_colors")), (
f"skin {name!r} declares both paired blocks; `colors` polarity "
f"would be ambiguous"
)
@pytest.mark.parametrize(("skin", "block", "palette", "is_light"), ALL_PALETTES, ids=PALETTE_IDS)
def test_palette_is_complete(skin, block, palette, is_light):
missing = REQUIRED_KEYS - palette.keys()
assert not missing, f"{skin}.{block} missing keys: {sorted(missing)}"
@pytest.mark.parametrize(("skin", "block", "palette", "is_light"), ALL_PALETTES, ids=PALETTE_IDS)
def test_palette_contrast_and_polarity(skin, block, palette, is_light):
pole = LIGHT_POLE if is_light else DARK_POLE
problems = []
for key in STRONG_FG:
ratio = contrast(palette[key], pole)
if ratio < STRONG_MIN:
problems.append(f"{key}={palette[key]} contrast {ratio:.2f} < {STRONG_MIN} vs {pole}")
for key in SOFT_FG:
ratio = contrast(palette[key], pole)
if ratio < SOFT_MIN:
problems.append(f"{key}={palette[key]} contrast {ratio:.2f} < {SOFT_MIN} vs {pole}")
status_bg = palette["status_bar_bg"]
for key in ON_STATUS_BAR:
floor = STRONG_MIN if key == "status_bar_strong" else SOFT_MIN
ratio = contrast(palette[key], status_bg)
if ratio < floor:
problems.append(f"{key}={palette[key]} contrast {ratio:.2f} < {floor} vs status_bar_bg {status_bg}")
for key in FILLS:
lum = luminance(palette[key])
if is_light and lum < 0.4:
problems.append(f"{key}={palette[key]} is a dark fill (lum {lum:.2f}) in a light palette")
if not is_light and lum > 0.35:
problems.append(f"{key}={palette[key]} is a light fill (lum {lum:.2f}) in a dark palette")
# The selection chip must remain distinguishable from the menu surface.
chip = contrast(palette["completion_menu_current_bg"], palette["completion_menu_bg"])
if chip < 1.15:
problems.append(
f"completion_menu_current_bg={palette['completion_menu_current_bg']} "
f"indistinguishable from completion_menu_bg (contrast {chip:.2f})"
)
assert not problems, f"{skin}.{block}:\n " + "\n ".join(problems)

View file

@ -2400,6 +2400,10 @@ def resolve_skin() -> dict:
return {
"name": skin.name,
"colors": skin.colors,
# Paired palettes: the TUI detects the terminal's polarity and
# prefers the matching hand-tuned block over adapting `colors`.
"light_colors": skin.light_colors,
"dark_colors": skin.dark_colors,
"branding": skin.branding,
"banner_logo": skin.banner_logo,
"banner_hero": skin.banner_hero,
@ -11804,6 +11808,15 @@ def _(rid, params: dict) -> dict:
_write_config_key("display.battery", nv_b)
return _ok(rid, {"key": key, "value": "on" if nv_b else "off"})
if key == "theme":
# TUI light/dark mode pin: 'light'/'dark' beat background
# auto-detection (xterm.js hosts misreport OSC 11); 'auto' trusts it.
raw = str(value or "").strip().lower()
if raw not in {"auto", "light", "dark"}:
return _err(rid, 4002, f"unknown theme value: {value} (use auto|light|dark)")
_write_config_key("display.tui_theme", raw)
return _ok(rid, {"key": key, "value": raw})
if key == "statusbar":
raw = str(value or "").strip().lower()
display = _load_cfg().get("display")
@ -12645,6 +12658,10 @@ def _(rid, params: dict) -> dict:
if key == "compact":
on = bool((_load_cfg().get("display") or {}).get("tui_compact", False))
return _ok(rid, {"value": "on" if on else "off"})
if key == "theme":
display = _load_cfg().get("display")
raw = str(display.get("tui_theme", "auto") if isinstance(display, dict) else "auto").strip().lower()
return _ok(rid, {"value": raw if raw in {"auto", "light", "dark"} else "auto"})
if key == "statusbar":
display = _load_cfg().get("display")
raw = (

View file

@ -26,7 +26,7 @@ export { default as measureElement } from './ink/measure-element.js'
export { scrollFastPathStats, type ScrollFastPathStats } from './ink/render-node-to-output.js'
export { createRoot, forceRedraw, default as render, renderSync } from './ink/root.js'
export { stringWidth } from './ink/stringWidth.js'
export { isXtermJs } from './ink/terminal.js'
export { isXtermJs, onTerminalBackground, parseOscColor, terminalBackgroundHex } from './ink/terminal.js'
export type { MouseTrackingMode } from './ink/termio/dec.js'
export { wrapAnsi } from './ink/wrapAnsi.js'

View file

@ -21,8 +21,8 @@ import {
import reconciler from '../reconciler.js'
import { clearSelection, finishSelection, hasSelection, type SelectionState, startSelection } from '../selection.js'
import { getTerminalFocused, setTerminalFocused } from '../terminal-focus-state.js'
import { decrqm, TerminalQuerier, xtversion } from '../terminal-querier.js'
import { isXtermJs, setXtversionName, supportsExtendedKeys } from '../terminal.js'
import { decrqm, oscColor, TerminalQuerier, xtversion } from '../terminal-querier.js'
import { isXtermJs, parseOscColor, setTerminalBackgroundHex, setXtversionName, supportsExtendedKeys } from '../terminal.js'
import {
DISABLE_KITTY_KEYBOARD,
DISABLE_MODIFY_OTHER_KEYS,
@ -336,14 +336,28 @@ export default class App extends PureComponent<Props, State> {
// init sequence completes — avoids interleaving with alt-screen/mouse
// tracking enable writes that may happen in the same render cycle.
setImmediate(() => {
void Promise.all([this.querier.send(xtversion()), this.querier.flush()]).then(([r]) => {
if (r) {
setXtversionName(r.name)
logForDebugging(`XTVERSION: terminal identified as "${r.name}"`)
} else {
logForDebugging('XTVERSION: no reply (terminal ignored query)')
// OSC 11 rides the same batch: the terminal's actual background
// color drives light/dark theme detection where env heuristics
// (COLORFGBG, TERM_PROGRAM) are blind — notably xterm.js hosts.
void Promise.all([this.querier.send(xtversion()), this.querier.send(oscColor(11)), this.querier.flush()]).then(
([r, bg]) => {
if (r) {
setXtversionName(r.name)
logForDebugging(`XTVERSION: terminal identified as "${r.name}"`)
} else {
logForDebugging('XTVERSION: no reply (terminal ignored query)')
}
const bgHex = bg ? parseOscColor(bg.data) : undefined
if (bgHex) {
setTerminalBackgroundHex(bgHex)
logForDebugging(`OSC11: terminal background is ${bgHex}`)
} else {
logForDebugging('OSC11: no reply (terminal ignored query)')
}
}
})
)
})
// Re-assert mouse tracking on raw-mode re-entry. <AlternateScreen>

View file

@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest'
import { INITIAL_STATE, parseMultipleKeypresses } from './parse-keypress.js'
import { oscColor, TerminalQuerier } from './terminal-querier.js'
import { parseOscColor } from './terminal.js'
// End-to-end (minus the PTY): a terminal's OSC 11 reply arriving on stdin
// must tokenize as a response, match the pending oscColor(11) query, and
// parse to a hex background. This is the exact chain App.tsx relies on for
// light/dark detection — if any segment drops the reply, detection dies
// silently because the querier never times out.
const parseWire = (raw: string) => {
const [keys] = parseMultipleKeypresses({ ...INITIAL_STATE }, raw)
return keys
}
const drainResponses = (raw: string) => parseWire(raw).filter(k => k.kind === 'response')
describe('OSC 11 reply chain', () => {
it.each([
['BEL-terminated', '\x1b]11;rgb:ffff/ffff/ffff\x07', '#ffffff'],
['ST-terminated', '\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\', '#1e1e2e']
])('resolves a pending query from a %s reply', async (_label, wire, expectedHex) => {
const writes: string[] = []
const stdout = { write: (s: string) => (writes.push(s), true) } as unknown as NodeJS.WriteStream
const querier = new TerminalQuerier(stdout)
const pending = querier.send(oscColor(11))
expect(writes.join('')).toContain('\x1b]11;?')
const responses = drainResponses(wire)
expect(responses).toHaveLength(1)
for (const r of responses) {
if (r.kind === 'response') {
querier.onResponse(r.response)
}
}
const reply = await pending
expect(reply?.type).toBe('osc')
if (reply?.type === 'osc') {
expect(reply.code).toBe(11)
expect(parseOscColor(reply.data)).toBe(expectedHex)
}
})
it('reply interleaved with keystrokes still resolves', async () => {
const stdout = { write: () => true } as unknown as NodeJS.WriteStream
const querier = new TerminalQuerier(stdout)
const pending = querier.send(oscColor(11))
const keys = parseWire('a\x1b]11;rgb:0000/0000/0000\x07b')
for (const k of keys) {
if (k.kind === 'response') {
querier.onResponse(k.response)
}
}
const reply = await pending
expect(reply?.type).toBe('osc')
// The surrounding keystrokes survive as normal input.
const chars = keys.filter(k => k.kind !== 'response')
expect(chars.length).toBeGreaterThanOrEqual(2)
})
})

View file

@ -0,0 +1,78 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { parseOscColor } from './terminal.js'
// setTerminalBackgroundHex is first-writer-wins module state — re-import a
// fresh module instance per test so cases don't contaminate each other.
async function freshTerminal() {
vi.resetModules()
return import('./terminal.js')
}
beforeEach(() => {
vi.resetModules()
})
describe('parseOscColor', () => {
it('parses the canonical 16-bit X11 rgb: form', () => {
expect(parseOscColor('rgb:1e1e/2a2a/3f3f')).toBe('#1e2a3f')
expect(parseOscColor('rgb:ffff/ffff/ffff')).toBe('#ffffff')
expect(parseOscColor('rgb:0000/0000/0000')).toBe('#000000')
})
it('scales shorter per-channel widths to 8 bits', () => {
expect(parseOscColor('rgb:f/f/f')).toBe('#ffffff')
expect(parseOscColor('rgb:ff/ff/ff')).toBe('#ffffff')
expect(parseOscColor('rgb:fff/fff/fff')).toBe('#ffffff')
expect(parseOscColor('rgb:8/0/0')).toBe('#880000')
})
it('accepts rgba: (alpha ignored) and plain hex forms', () => {
expect(parseOscColor('rgba:1e1e/2a2a/3f3f/ffff')).toBe('#1e2a3f')
expect(parseOscColor('#1e2a3f')).toBe('#1e2a3f')
expect(parseOscColor('1e2a3f')).toBe('#1e2a3f')
expect(parseOscColor('#abc')).toBe('#aabbcc')
expect(parseOscColor('#1e1e2a2a3f3f')).toBe('#1e2a3f')
})
it('rejects garbage', () => {
expect(parseOscColor('')).toBeUndefined()
expect(parseOscColor('rgb:zz/zz/zz')).toBeUndefined()
expect(parseOscColor('rgb:1e1e/2a2a')).toBeUndefined()
expect(parseOscColor('notacolor')).toBeUndefined()
})
})
describe('terminal background storage', () => {
it('fires queued listeners once when the background arrives', async () => {
const t = await freshTerminal()
const seen: string[] = []
t.onTerminalBackground(hex => seen.push(hex))
expect(t.terminalBackgroundHex()).toBeUndefined()
t.setTerminalBackgroundHex('#ffffff')
expect(seen).toEqual(['#ffffff'])
expect(t.terminalBackgroundHex()).toBe('#ffffff')
})
it('fires immediately for listeners registered after the answer', async () => {
const t = await freshTerminal()
t.setTerminalBackgroundHex('#1e1e2e')
const seen: string[] = []
t.onTerminalBackground(hex => seen.push(hex))
expect(seen).toEqual(['#1e1e2e'])
})
it('is first-writer-wins (re-probe defense)', async () => {
const t = await freshTerminal()
t.setTerminalBackgroundHex('#ffffff')
t.setTerminalBackgroundHex('#000000')
expect(t.terminalBackgroundHex()).toBe('#ffffff')
})
})

View file

@ -172,6 +172,104 @@ export function needsAltScreenResizeScrollbackClear(env: NodeJS.ProcessEnv = pro
return (env.TERM_PROGRAM ?? '').trim() === 'Apple_Terminal'
}
// -- OSC-11-detected terminal background (populated async at startup) --
//
// Env heuristics (COLORFGBG, TERM_PROGRAM allow-lists) can't see the actual
// terminal background — xterm.js hosts (VS Code / Cursor) set neither, so a
// light-themed editor terminal reads as "dark" and gets an unreadable
// palette. OSC 11 asks the terminal directly; App.tsx fires the query in the
// same startup batch as XTVERSION and calls setTerminalBackgroundHex() when
// the reply lands. Readers treat undefined as "not yet known / unsupported".
let terminalBackground: string | undefined
const terminalBackgroundListeners = new Set<(hex: string) => void>()
/** Record the OSC 11 response. First writer wins (defend against re-probe). */
export function setTerminalBackgroundHex(hex: string): void {
if (terminalBackground !== undefined) {
return
}
terminalBackground = hex
for (const listener of terminalBackgroundListeners) {
listener(hex)
}
terminalBackgroundListeners.clear()
}
/** The terminal's reported background as `#rrggbb`, or undefined if the
* reply hasn't arrived (or the terminal ignored the query). */
export function terminalBackgroundHex(): string | undefined {
return terminalBackground
}
/** Subscribe to the background color. Fires immediately when already known,
* otherwise once when the OSC 11 reply arrives. */
export function onTerminalBackground(listener: (hex: string) => void): void {
if (terminalBackground !== undefined) {
listener(terminalBackground)
return
}
terminalBackgroundListeners.add(listener)
}
/**
* Parse an OSC color reply payload into `#rrggbb`.
*
* Terminals answer OSC 10/11 queries with X11 color specs: most commonly
* `rgb:RRRR/GGGG/BBBB` (1-4 hex digits per channel, scaled to the channel
* max), sometimes `rgba:...` (alpha ignored) or a plain `#hex` form.
* Returns undefined for anything unrecognized.
*/
export function parseOscColor(data: string): string | undefined {
const value = data.trim().toLowerCase()
const scaled = (component: string): null | number => {
if (!/^[0-9a-f]{1,4}$/.test(component)) {
return null
}
const max = 16 ** component.length - 1
return Math.round((parseInt(component, 16) / max) * 255)
}
const rgbMatch = /^rgba?:([0-9a-f]{1,4})\/([0-9a-f]{1,4})\/([0-9a-f]{1,4})(?:\/[0-9a-f]{1,4})?$/.exec(value)
if (rgbMatch) {
const channels = [rgbMatch[1]!, rgbMatch[2]!, rgbMatch[3]!].map(scaled)
if (channels.every(c => c !== null)) {
return '#' + channels.map(c => c!.toString(16).padStart(2, '0')).join('')
}
return undefined
}
const hexMatch = /^#?([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{12})$/.exec(value)
if (!hexMatch) {
return undefined
}
const hex = hexMatch[1]!
if (hex.length === 6) {
return `#${hex}`
}
if (hex.length === 3) {
return `#${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}`
}
// 12-digit form: 4 digits per channel, take the top byte of each.
return `#${hex.slice(0, 2)}${hex.slice(4, 6)}${hex.slice(8, 10)}`
}
// Terminals known to correctly implement the Kitty keyboard protocol
// (CSI >1u) and/or xterm modifyOtherKeys (CSI >4;2m) for ctrl+shift+<letter>
// disambiguation. We previously enabled unconditionally (#23350), assuming

View file

@ -26,6 +26,7 @@ import {
sessionRowKindAt,
sessionsCountLabel
} from '../components/activeSessionSwitcher.js'
import { listRowStyle } from '../components/overlayPrimitives.js'
import type { SessionActiveItem } from '../gatewayTypes.js'
import type { SessionListItem } from '../gatewayTypes.js'
import { DEFAULT_THEME } from '../theme.js'
@ -75,13 +76,19 @@ describe('session orchestrator helpers', () => {
expect(newSessionMarkerColor(DEFAULT_THEME, true)).toBe(DEFAULT_THEME.color.text)
})
it('uses a readable selected row style instead of accent-on-accent inverse text', () => {
it('uses the shared list-row primitive for the selected row (same as completions)', () => {
const style = selectedSessionRowStyle(DEFAULT_THEME)
const shared = listRowStyle(DEFAULT_THEME, true)
expect(style.backgroundColor).toBe(DEFAULT_THEME.color.selectionBg)
expect(style.color).toBe(DEFAULT_THEME.color.text)
// One source of truth: the session switcher and the completions popover
// cannot disagree about what "selected" looks like.
expect(style.backgroundColor).toBe(shared.backgroundColor)
expect(style.color).toBe(shared.color)
// Readability contract survives: never accent-on-accent inverse.
expect(style.backgroundColor).not.toBe(DEFAULT_THEME.color.accent)
expect(style.color).not.toBe(DEFAULT_THEME.color.accent)
// Inactive rows paint nothing — the terminal's canvas is the row bg.
expect(listRowStyle(DEFAULT_THEME, false)).toEqual({})
})
it('turns model picker values into session-scoped draft model args', () => {

View file

@ -723,6 +723,26 @@ describe('createGatewayEventHandler', () => {
expect(ctx.gateway.rpc).not.toHaveBeenCalled()
})
it('picks the polarity-matching paired palette from gateway.ready skins', async () => {
const appended: Msg[] = []
const skin = {
colors: { banner_title: '#00FF88', banner_text: '#FFF8DC' },
light_colors: { banner_title: '#8B0000', banner_text: '#22201C' }
}
// Dark terminal (clean env): the dark-authored `colors` block wins.
vi.stubEnv('HERMES_TUI_BACKGROUND', '')
createGatewayEventHandler(buildCtx(appended))({ payload: skin, type: 'skin.changed' } as any)
expect(getUiState().theme.color.primary).toBe('#00FF88')
// Light terminal: the hand-tuned light_colors block wins over adaptation.
vi.stubEnv('HERMES_TUI_BACKGROUND', '#ffffff')
createGatewayEventHandler(buildCtx(appended))({ payload: skin, type: 'skin.changed' } as any)
expect(getUiState().theme.color.primary).toBe('#8B0000')
vi.unstubAllEnvs()
})
it('on gateway.ready with no STARTUP_RESUME_ID and auto_resume off, forges a new session', async () => {
const appended: Msg[] = []
const newSession = vi.fn()

View file

@ -200,8 +200,10 @@ describe('fromSkin', () => {
expect(fromSkin({ banner_title: '#FF0000' }, {}).color.accent).toBe(DEFAULT_THEME.color.accent)
})
it('derives completion current background from resolved completion background', async () => {
const { fromSkin } = await importThemeWithCleanEnv()
it('derives completion current background from resolved completion background (polarity-compatible)', async () => {
// Light terminal + light-authored menu fill: the skin's fill is honored
// and the current-row derivation mixes off it.
const { fromSkin } = await importThemeWithEnv({ HERMES_TUI_BACKGROUND: '#ffffff' })
const theme = fromSkin({ banner_accent: '#000000', completion_menu_bg: '#ffffff' }, {})
@ -209,6 +211,17 @@ describe('fromSkin', () => {
expect(theme.color.completionCurrentBg).toBe('#bfbfbf')
})
it('rejects wrong-polarity fills even when skin-authored (terminal owns the canvas)', async () => {
// Dark terminal + white menu fill: unlike the desktop app, the TUI cannot
// paint its own canvas, so cross-polarity fills fall back to the base.
const { DARK_THEME, fromSkin } = await importThemeWithCleanEnv()
const theme = fromSkin({ banner_accent: '#000000', completion_menu_bg: '#ffffff' }, {})
expect(theme.color.completionBg).toBe(DARK_THEME.color.completionBg)
expect(theme.color.completionCurrentBg).toBe(DARK_THEME.color.completionCurrentBg)
})
it('uses active completion color as the selection highlight fallback', async () => {
const { fromSkin } = await importThemeWithCleanEnv()
@ -286,11 +299,15 @@ describe('fromSkin', () => {
expect(theme.color.prompt).toBe('ansi256(136)')
})
it('does not normalize light Apple Terminal when truecolor is advertised', async () => {
it('keeps truecolor light Apple Terminal in truecolor (adapting, not ansi256-bucketing)', async () => {
const { fromSkin } = await importThemeWithEnv({ COLORTERM: 'truecolor', TERM_PROGRAM: 'Apple_Terminal' })
const theme = fromSkin({ banner_text: '#FFF8DC' }, {})
expect(theme.color.text).toBe('#FFF8DC')
// No ansi256 bucketing on truecolor terminals — but a cream foreground on
// a light background is exactly the washout the adaptation exists to fix,
// so the value is clamped to a readable truecolor hex rather than kept.
expect(theme.color.text).toMatch(/^#[0-9a-f]{6}$/i)
expect(luminance(theme.color.text)).toBeLessThanOrEqual(0.45)
})
it('normalizes Apple Terminal names before matching', async () => {
@ -311,7 +328,101 @@ describe('fromSkin', () => {
const { fromSkin } = await importThemeWithCleanEnv()
const { color } = fromSkin({ ui_ok: '#008000' }, {})
expect(color.ok).toBe('#008000')
expect(color.statusGood).toBe('#008000')
// The exact value may be contrast-lifted against the background; the
// contract is the cascade (ok drives statusGood) and the hue surviving.
expect(color.statusGood).toBe(color.ok)
expect(color.ok).toMatch(/^#[0-9a-f]{6}$/i)
expect(luminance(color.ok)).toBeGreaterThan(0)
})
})
// Rec. 709-ish relative luminance, local to the test so assertions are
// independent of the implementation under test.
const luminance = (hex: string): number => {
const n = parseInt(hex.replace('#', ''), 16)
const channel = (v: number) => {
const c = v / 255
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
}
return 0.2126 * channel((n >> 16) & 0xff) + 0.7152 * channel((n >> 8) & 0xff) + 0.0722 * channel(n & 0xff)
}
// The bundled slate skin's actual color block — dark-authored (pale pastels,
// no completion/selection backgrounds defined).
const SLATE_COLORS = {
banner_accent: '#8EA8FF',
banner_border: '#4169e1',
banner_dim: '#4b5563',
banner_text: '#c9d1d9',
banner_title: '#7eb8f6',
prompt: '#c9d1d9',
session_border: '#4b5563',
session_label: '#7eb8f6',
ui_accent: '#7eb8f6',
ui_error: '#F7A072',
ui_label: '#8EA8FF',
ui_ok: '#63D0A6',
ui_warn: '#e6a855'
}
describe('background-aware adaptation (OSC-11 light terminals)', () => {
it('keeps a dark-authored skin readable on a light background', async () => {
const { contrastRatio, fromSkin } = await importThemeWithEnv({ HERMES_TUI_BACKGROUND: '#ffffff' })
const { color } = fromSkin(SLATE_COLORS, {})
// Foreground roles must clear WCAG contrast against the actual white
// background — hue survives, washout doesn't.
for (const key of ['text', 'prompt', 'accent', 'label', 'ok', 'error', 'primary'] as const) {
expect(contrastRatio(color[key], '#ffffff'), `${key} ${color[key]}`).toBeGreaterThanOrEqual(3.8)
}
// Softer roles (muted/warn/border) get a looser but real floor.
for (const key of ['muted', 'warn', 'border'] as const) {
expect(contrastRatio(color[key], '#ffffff'), `${key} ${color[key]}`).toBeGreaterThanOrEqual(2.7)
}
// Background roles the skin never defined must be light-polarity fills,
// not the dark base's navy.
for (const key of ['completionBg', 'completionCurrentBg', 'statusBg', 'selectionBg'] as const) {
expect(luminance(color[key]), `${key} ${color[key]}`).toBeGreaterThanOrEqual(0.4)
}
})
it('leaves the same skin untouched on a dark background', async () => {
const { fromSkin } = await importThemeWithEnv({ HERMES_TUI_BACKGROUND: '#1e1e2e' })
const { color } = fromSkin(SLATE_COLORS, {})
expect(color.text).toBe('#c9d1d9')
expect(color.accent).toBe('#7eb8f6')
expect(luminance(color.completionBg)).toBeLessThanOrEqual(0.35)
})
it('empty skin on a light background resolves to the light base palette', async () => {
const { fromSkin, LIGHT_THEME } = await importThemeWithEnv({ HERMES_TUI_BACKGROUND: '#ffffff' })
expect(fromSkin({}, {}).color).toEqual(LIGHT_THEME.color)
})
it('base palettes are fixed points of the adaptation', async () => {
const dark = await importThemeWithCleanEnv()
expect(dark.fromSkin({}, {}).color).toEqual(dark.DARK_THEME.color)
const light = await importThemeWithEnv({ HERMES_TUI_BACKGROUND: '#fafafa' })
expect(light.fromSkin({}, {}).color).toEqual(light.LIGHT_THEME.color)
})
it('defaultThemeForCurrentBackground follows a late HERMES_TUI_BACKGROUND write', async () => {
const { DEFAULT_THEME, defaultThemeForCurrentBackground, LIGHT_THEME } = await importThemeWithCleanEnv()
// Module loaded dark (clean env)…
expect(DEFAULT_THEME.color.completionBg).toBe('#1a1a2e')
// …then the OSC-11 answer lands and is cached into the env slot.
expect(defaultThemeForCurrentBackground({ HERMES_TUI_BACKGROUND: '#ffffff' }).color).toEqual(LIGHT_THEME.color)
})
})

View file

@ -1,3 +1,5 @@
import { onTerminalBackground } from '@hermes/ink'
import { STARTUP_IMAGE, STARTUP_QUERY } from '../config/env.js'
import { STREAM_BATCH_MS } from '../config/timing.js'
import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js'
@ -14,7 +16,7 @@ import { openExternalUrl } from '../lib/openExternalUrl.js'
import { rpcErrorMessage } from '../lib/rpc.js'
import { topLevelSubagents } from '../lib/subagentTree.js'
import { formatAbandonedClarify, formatToolCall, stripAnsi } from '../lib/text.js'
import { fromSkin } from '../theme.js'
import { defaultThemeForCurrentBackground, detectLightMode, fromSkin } from '../theme.js'
import type { Msg, SubagentProgress, SubagentStatus } from '../types.js'
import { applyDelegationStatus, getDelegationState } from './delegationStore.js'
@ -29,17 +31,99 @@ const NO_PROVIDER_RE = /\bNo (?:LLM|inference) provider configured\b/i
const statusFromBusy = () => (getUiState().busy ? 'running…' : 'ready')
const applySkin = (s: GatewaySkin) =>
patchUiState({
theme: fromSkin(
s.colors ?? {},
s.branding ?? {},
s.banner_logo ?? '',
s.banner_hero ?? '',
s.tool_prefix ?? '',
s.help_header ?? ''
)
// The last gateway skin, kept so the theme can be re-derived when the OSC-11
// background answer arrives after (or without) gateway.ready.
let lastSkin: GatewaySkin | null = null
const themeForSkin = (s: GatewaySkin) => {
// Prefer the skin's hand-tuned palette for the terminal's polarity
// (desktop colors/darkColors contract). Without a paired block, `colors`
// goes through fromSkin's automatic contrast adaptation instead.
const paired = detectLightMode() ? s.light_colors : s.dark_colors
const colors = paired && Object.keys(paired).length ? paired : (s.colors ?? {})
return fromSkin(colors, s.branding ?? {}, s.banner_logo ?? '', s.banner_hero ?? '', s.tool_prefix ?? '', s.help_header ?? '')
}
const applySkin = (s: GatewaySkin) => {
lastSkin = s
patchUiState({ theme: themeForSkin(s) })
}
/** Re-derive the theme from current detection signals (env overrides, cached
* OSC-11 answer) used by /theme, config sync, and the OSC listener. */
export function reapplyTheme(): void {
patchUiState({ theme: lastSkin ? themeForSkin(lastSkin) : defaultThemeForCurrentBackground() })
}
/**
* Apply the persisted mode pin (`display.tui_theme`). 'light'/'dark' bridge
* to HERMES_TUI_THEME the priority-2 signal `detectLightMode` already
* honors (only an explicit HERMES_TUI_LIGHT env var outranks it); 'auto'
* clears the pin so the OSC-11 probe + env heuristics decide. The pin exists
* because the probe cannot always be trusted: xterm.js hosts report #000000
* regardless of the painted background when the editor theme leaves the
* terminal background unset.
*/
export function applyConfiguredTuiTheme(raw: unknown): void {
const mode = String(raw ?? '')
.trim()
.toLowerCase()
const current = process.env.HERMES_TUI_THEME ?? ''
if (mode === 'light' || mode === 'dark') {
if (current === mode) {
return
}
process.env.HERMES_TUI_THEME = mode
} else {
if (!current) {
return
}
delete process.env.HERMES_TUI_THEME
}
reapplyTheme()
}
let themeBackgroundSyncStarted = false
/**
* Re-derive the theme from the terminal's ACTUAL background color once the
* OSC-11 probe answers. The env heuristics `detectLightMode` runs at module
* load are blind in xterm.js hosts (VS Code / Cursor set no COLORFGBG), so a
* light editor terminal otherwise gets the dark fallback palette. The answer
* is cached into HERMES_TUI_BACKGROUND the slot `detectLightMode` already
* reads (and child processes inherit) then the current skin (or the
* skinless default) is re-applied against the corrected base. Explicit
* HERMES_TUI_LIGHT / HERMES_TUI_THEME overrides still win inside
* detectLightMode, so users can pin a mode regardless of the probe.
*/
export function syncThemeToTerminalBackground(): void {
if (themeBackgroundSyncStarted) {
return
}
themeBackgroundSyncStarted = true
onTerminalBackground(hex => {
// xterm.js hosts (VS Code / Cursor) answer OSC 11 with #000000 when the
// editor theme sets no explicit terminal background — the renderer's
// unset DEFAULT, not the painted color (observed: pure black reported on
// a white terminal). A real vscode dark theme reports its actual surface
// (#1e1e1e etc.), so exactly-#000000 from a vscode host carries no
// signal: skip the cache and leave detection to env/config overrides.
if (hex === '#000000' && (process.env.TERM_PROGRAM ?? '') === 'vscode') {
return
}
process.env.HERMES_TUI_BACKGROUND = hex
reapplyTheme()
})
}
const dropBgTask = (taskId: string) =>
patchUiState(state => {
@ -79,6 +163,8 @@ const normalizeSubagentStatus = (status: unknown, fallback: SubagentStatus): Sub
}
export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: GatewayEvent) => void {
syncThemeToTerminalBackground()
const { rpc } = ctx.gateway
const { STARTUP_RESUME_ID, newSession, recoverSidRef, resumeById, setCatalog } = ctx.session
const { bellOnComplete, stdout, sys } = ctx.system

View file

@ -1,6 +1,10 @@
import { terminalBackgroundHex } from '@hermes/ink'
import { formatBytes, performHeapDump } from '../../../lib/memory.js'
import { detectLightMode } from '../../../theme.js'
import type { DialogState } from '../../interfaces.js'
import { patchOverlayState } from '../../overlayStore.js'
import { getUiState } from '../../uiStore.js'
import type { SlashCommand } from '../types.js'
const GRID_TEST_USAGE = 'usage: /grid-test [cols]x[rows] · /grid-test [cols] [rows] · /grid-test streams'
@ -131,6 +135,31 @@ export const debugCommands: SlashCommand[] = [
}
},
{
help: 'print live theme diagnostics (background probe, light mode, palette)',
name: 'theme-info',
run: (_arg, ctx) => {
const { theme } = getUiState()
ctx.transcript.panel('Theme', [
{
rows: [
['OSC-11 background', terminalBackgroundHex() ?? '(no reply)'],
['HERMES_TUI_BACKGROUND', process.env.HERMES_TUI_BACKGROUND ?? '(unset)'],
['HERMES_TUI_THEME', process.env.HERMES_TUI_THEME ?? '(unset)'],
['COLORFGBG', process.env.COLORFGBG ?? '(unset)'],
['TERM_PROGRAM', process.env.TERM_PROGRAM ?? '(unset)'],
['detected mode', detectLightMode() ? 'light' : 'dark'],
['text', theme.color.text],
['completionBg', theme.color.completionBg],
['selectionBg', theme.color.selectionBg],
['statusBg', theme.color.statusBg]
]
}
])
}
},
{
help: 'print live V8 heap + rss numbers',
name: 'mem',

View file

@ -15,6 +15,7 @@ import type {
import { formatVoiceRecordKey, parseVoiceRecordKey } from '../../../lib/platform.js'
import { fmtK } from '../../../lib/text.js'
import type { PanelSection } from '../../../types.js'
import { applyConfiguredTuiTheme } from '../../createGatewayEventHandler.js'
import { DEFAULT_INDICATOR_STYLE, INDICATOR_STYLES, type IndicatorStyle } from '../../interfaces.js'
import { patchOverlayState } from '../../overlayStore.js'
import { patchUiState } from '../../uiStore.js'
@ -414,6 +415,31 @@ export const sessionCommands: SlashCommand[] = [
}
},
{
help: 'pin light/dark mode or trust auto-detection (usage: /theme [auto|light|dark])',
name: 'theme',
usage: '/theme [auto|light|dark]',
run: (arg, ctx) => {
const value = arg.trim().toLowerCase()
if (!value) {
return ctx.gateway
.rpc<ConfigGetValueResponse>('config.get', { key: 'theme' })
.then(ctx.guarded<ConfigGetValueResponse>(r => ctx.transcript.sys(`theme: ${r.value || 'auto'}`)))
}
if (!['auto', 'light', 'dark'].includes(value)) {
return ctx.transcript.sys('usage: /theme [auto|light|dark]')
}
// Apply immediately, persist in the background.
applyConfiguredTuiTheme(value)
ctx.gateway
.rpc<ConfigSetResponse>('config.set', { key: 'theme', value })
.then(ctx.guarded<ConfigSetResponse>(r => r.value !== undefined && ctx.transcript.sys(`theme → ${value}`)))
}
},
{
help: 'switch theme skin (fires skin.changed)',
name: 'skin',

View file

@ -7,6 +7,7 @@ import type { ConfigFullResponse, ConfigMtimeResponse, ReloadMcpResponse } from
import { DEFAULT_VOICE_RECORD_KEY, type ParsedVoiceRecordKey, parseVoiceRecordKey } from '../lib/platform.js'
import { asRpcResult } from '../lib/rpc.js'
import { applyConfiguredTuiTheme } from './createGatewayEventHandler.js'
import {
type BusyInputMode,
DEFAULT_INDICATOR_STYLE,
@ -205,6 +206,8 @@ export const applyDisplay = (
setBell(!!d.bell_on_complete)
applyConfiguredTuiTheme(d.tui_theme)
// Only push the voice record key when the RPC actually returned a
// config payload. ``quietRpc()`` collapses failures to ``null``; if we
// reset the cached shortcut on every null we would clobber a custom

View file

@ -16,6 +16,7 @@ import type { Theme } from '../theme.js'
import { ModelPicker } from './modelPicker.js'
import { windowOffset } from './overlayControls.js'
import { listRowStyle } from './overlayPrimitives.js'
import { TextInput } from './textInput.js'
const VISIBLE = 12
@ -153,9 +154,12 @@ export const orchestratorHintSegmentColor = (t: Theme, role: OrchestratorHintRol
return t.color.muted
}
// Delegates to the shared list-row primitive so the session switcher and the
// completions popover cannot disagree about what "selected" looks like.
// (`selectionBg` remains the TEXT-selection highlight — a different semantic.)
export const selectedSessionRowStyle = (t: Theme) => ({
backgroundColor: t.color.selectionBg,
color: t.color.text
backgroundColor: listRowStyle(t, true).backgroundColor,
color: listRowStyle(t, true).color
})
export const newSessionMarkerColor = (t: Theme, selected: boolean) =>

View file

@ -418,6 +418,7 @@ const ComposerPane = memo(function ComposerPane({
onPaste={composer.handleTextPaste}
onSubmit={composer.submit}
placeholder={composer.empty ? PLACEHOLDER : ui.busy ? 'Ctrl+C to interrupt…' : ''}
placeholderColor={ui.theme.color.muted}
value={composer.input}
voiceRecordKey={composer.voiceRecordKey}
/>

View file

@ -14,6 +14,7 @@ import { GridTestOverlay } from './gridTestOverlay.js'
import { MaskedPrompt } from './maskedPrompt.js'
import { ModelPicker } from './modelPicker.js'
import { OverlayHint } from './overlayControls.js'
import { listRowStyle } from './overlayPrimitives.js'
import { PetPicker } from './petPicker.js'
import { PluginsHub } from './pluginsHub.js'
import { ApprovalPrompt, ClarifyPrompt, ConfirmPrompt } from './prompts.js'
@ -346,13 +347,20 @@ export function FloatingOverlays({
id: 'completions',
render: () => (
<FloatBox color={theme.color.primary}>
{/* No painted panel fill: FloatBox is `opaque`, so rows sit on the
terminal's own background the one color that is always right
on a canvas we don't own (a full completionBg fill was the lone
surface painting its own background, which is why it could
disagree with every other overlay). Only the ACTIVE row carries
a selection chip, mirroring the session switcher. */}
<Box flexDirection="column" width={Math.max(28, cols - 6)}>
{completions.slice(start, start + viewportSize).map((item, i) => {
const active = start + i === compIdx
const row = listRowStyle(theme, active)
return (
<Box
backgroundColor={active ? theme.color.completionCurrentBg : theme.color.completionBg}
backgroundColor={row.backgroundColor}
flexDirection="row"
key={`${start + i}:${item.text}:${item.display}:${item.meta ?? ''}`}
width="100%"
@ -367,10 +375,7 @@ export function FloatingOverlays({
</Text>
</Box>
{item.meta ? (
<Text
backgroundColor={active ? theme.color.completionMetaCurrentBg : theme.color.completionMetaBg}
color={theme.color.muted}
>
<Text backgroundColor={row.backgroundColor} color={theme.color.muted}>
{' '}
{item.meta}
</Text>

View file

@ -51,11 +51,28 @@ export function useMenu(rows: MenuRowSpec[], onEscape: () => void, onKey?: (ch:
return Math.min(sel, Math.max(0, rows.length - 1))
}
/** A numbered menu row with the ▸ cursor (mirrors ClarifyPrompt). */
/**
* THE selected-row treatment for every list surface (completions popover,
* session switcher, pickers): a `selection` chip on the active row, nothing
* painted otherwise. Panels never paint their own full background floating
* surfaces are `opaque` (terminal-native canvas) and only the active row
* carries color, so list surfaces cannot disagree about "selected" and stay
* correct on any terminal background. Callers own layout; this owns color.
*/
export function listRowStyle(t: Theme, active: boolean): { backgroundColor?: string; color?: string } {
return active ? { backgroundColor: t.color.completionCurrentBg, color: t.color.text } : {}
}
/** A numbered menu row with the cursor (mirrors ClarifyPrompt). Active rows
* carry the shared list-row selection chip same treatment as completions
* and the session switcher instead of `inverse`, whose contrast depends on
* the terminal's unknowable default colors. */
export function MenuRow({ active, index, label, t }: { active: boolean; index: number; label: string; t: Theme }) {
const row = listRowStyle(t, active)
return (
<Text>
<Text bold={active} color={active ? t.color.label : t.color.muted} inverse={active}>
<Text backgroundColor={row.backgroundColor} bold={active} color={active ? (row.color ?? t.color.label) : t.color.muted}>
{active ? '▸ ' : ' '}
{index}. {label}
</Text>

View file

@ -43,6 +43,21 @@ type MinimalEnv = Record<string, string | undefined>
const invert = (s: string) => INV + s + INV_OFF
const dim = (s: string) => DIM + s + DIM_OFF
/** Truecolor foreground wrap; falls back to SGR dim when no hex is given so
* the placeholder can follow the THEME's muted color instead of whatever the
* terminal's default-foreground dim happens to look like. */
const colorizeHint = (s: string, hex?: string) => {
const m = hex ? /^#([0-9a-f]{6})$/i.exec(hex) : null
if (!m) {
return dim(s)
}
const n = parseInt(m[1]!, 16)
return `${ESC}[38;2;${(n >> 16) & 0xff};${(n >> 8) & 0xff};${n & 0xff}m${s}${ESC}[39m`
}
let _seg: Intl.Segmenter | null = null
const seg = () => (_seg ??= new Intl.Segmenter(undefined, { granularity: 'grapheme' }))
const STOP_CACHE_MAX = 32
@ -540,6 +555,7 @@ export function TextInput({
mouseApiRef,
voiceRecordKey = DEFAULT_VOICE_RECORD_KEY,
placeholder = '',
placeholderColor,
focus = true
}: TextInputProps) {
const [cur, setCur] = useState(value.length)
@ -620,17 +636,20 @@ export function TextInput({
const nativeCursor = focus && termFocus && !selected && !!stdout?.isTTY
// Placeholder text is just a hint, not a selection — render it dim
// without inverse styling. In a TTY the hardware cursor parks at column
// 0 and visually marks the input start. Non-TTY surfaces still need the
// synthetic inverse first-char to draw a cursor at all.
// Placeholder text is just a hint, not a selection — render it in the
// theme's muted color (SGR dim as fallback) without inverse styling. In a
// TTY the hardware cursor parks at column 0 and visually marks the input
// start. Non-TTY surfaces still need the synthetic inverse first-char to
// draw a cursor at all.
const rendered = useMemo(() => {
if (!focus) {
return display || dim(placeholder)
return display || colorizeHint(placeholder, placeholderColor)
}
if (!display && placeholder) {
return nativeCursor ? dim(placeholder) : invert(placeholder[0] ?? ' ') + dim(placeholder.slice(1))
return nativeCursor
? colorizeHint(placeholder, placeholderColor)
: invert(placeholder[0] ?? ' ') + colorizeHint(placeholder.slice(1), placeholderColor)
}
if (selected) {
@ -638,7 +657,7 @@ export function TextInput({
}
return nativeCursor ? display || ' ' : renderWithCursor(display, cur)
}, [cur, display, focus, nativeCursor, placeholder, selected])
}, [cur, display, focus, nativeCursor, placeholder, placeholderColor, selected])
useEffect(() => {
if (self.current) {
@ -1387,6 +1406,8 @@ interface TextInputProps {
) => { cursor: number; value: string } | Promise<{ cursor: number; value: string } | null> | null
onSubmit?: (v: string) => void
placeholder?: string
/** Hex color for placeholder text (theme muted); SGR dim when omitted. */
placeholderColor?: string
value: string
voiceRecordKey?: ParsedVoiceRecordKey
}

View file

@ -7,7 +7,11 @@ export interface GatewaySkin {
banner_logo?: string
branding?: Record<string, string>
colors?: Record<string, string>
/** Hand-tuned palette for dark terminals (light-authored skins). */
dark_colors?: Record<string, string>
help_header?: string
/** Hand-tuned palette for light terminals (dark-authored skins). */
light_colors?: Record<string, string>
tool_prefix?: string
}
@ -103,6 +107,9 @@ export interface ConfigDisplayConfig {
// validation anyway.
tui_status_indicator?: string
tui_statusbar?: 'bottom' | 'off' | 'on' | 'top' | boolean
/** Theme mode pin: 'light' / 'dark' beat background auto-detection; 'auto'
* (default) trusts the OSC-11 probe + env signals. */
tui_theme?: string
}
export interface ConfigVoiceConfig {

View file

@ -158,6 +158,12 @@ function circularDistance(a: number, b: number): number {
return Math.min(distance, 1 - distance)
}
function hexLuminance(color: string): null | number {
const rgb = parseHex(color)
return rgb ? relativeLuminance(rgb[0], rgb[1], rgb[2]) : null
}
// Mirrors @hermes/ink's colorize.ts. Keep local: app code compiles from
// ui-tui/src, while @hermes/ink is bundled separately from packages/.
function richEightBitColorNumber(red: number, green: number, blue: number): number {
@ -346,6 +352,147 @@ export const LIGHT_THEME: Theme = {
bannerHero: ''
}
// ── Background-aware readability adaptation ─────────────────────────
//
// Mirrors the desktop app's theme contract (apps/desktop/src/themes): skins
// contribute accent IDENTITY; readability against the actual background is
// the theme engine's job, enforced in one place. Two guards, in the desktop's
// vocabulary:
//
// * `ensureContrast` — foreground-role colors are step-mixed toward the
// readable pole (black on light, white on dark) until they clear a
// minimum WCAG contrast ratio against the real (or assumed) background.
// Hue survives; washout doesn't.
// * Fill polarity — background-role colors (completion menu, status bar,
// selection) must match the terminal's polarity. Unlike the desktop, the
// TUI does not own its canvas — panels sit directly on the terminal's
// background — so a wrong-polarity fill (navy menu on a white terminal)
// falls back to the base palette even when the skin authored it.
/** WCAG contrast ratio between two hex colors (121). Null if unparseable. */
export function contrastRatio(a: string, b: string): null | number {
const la = hexLuminance(a)
const lb = hexLuminance(b)
if (la === null || lb === null) {
return null
}
const [hi, lo] = la >= lb ? [la, lb] : [lb, la]
return (hi + 0.05) / (lo + 0.05)
}
/**
* Step-mix `color` toward the readable pole of `bg` until the contrast
* ratio clears `min` (desktop `color.ts::ensureContrast`). Returns the
* original when it already passes or isn't hex.
*/
export function ensureContrast(color: string, bg: string, min: number): string {
const bgLuminance = hexLuminance(bg)
if (bgLuminance === null || parseHex(color) === null) {
return color
}
const pole = bgLuminance > 0.5 ? '#000000' : '#ffffff'
let current = color
for (let step = 0; step <= 20; step++) {
const ratio = contrastRatio(current, bg)
if (ratio === null || ratio >= min) {
return current
}
current = mix(color, pole, Math.min(1, (step + 1) * 0.05))
}
return current
}
// Contrast tiers, tuned so both base palettes are fixed points: primary
// text/labels/status readable at ≥ 3.9; muted/secondary/ambers at ≥ 2.8.
const STRONG_MIN_CONTRAST = 3.9
const SOFT_MIN_CONTRAST = 2.8
const STRONG_FOREGROUNDS: readonly (keyof ThemeColors)[] = [
'primary',
'accent',
'text',
'label',
'ok',
'error',
'prompt',
'statusFg',
'statusGood',
'statusBad',
'statusCritical',
'shellDollar'
]
const SOFT_FOREGROUNDS: readonly (keyof ThemeColors)[] = [
'border',
'muted',
'warn',
'sessionLabel',
'sessionBorder',
'statusWarn'
]
const ADAPTIVE_BACKGROUNDS: readonly (keyof ThemeColors)[] = [
'completionBg',
'completionCurrentBg',
'completionMetaBg',
'completionMetaCurrentBg',
'statusBg',
'selectionBg'
]
// Fill polarity limits: on light terminals a fill must stay light, and vice
// versa — there is no readable middle for a panel fill on the wrong pole.
const LIGHT_BG_MIN_LUMINANCE = 0.4
const DARK_BG_MAX_LUMINANCE = 0.35
function adaptColorsToBackground(colors: ThemeColors, isLight: boolean, base: ThemeColors, bg: string): ThemeColors {
const out = { ...colors }
for (const key of STRONG_FOREGROUNDS) {
out[key] = ensureContrast(out[key], bg, STRONG_MIN_CONTRAST)
}
for (const key of SOFT_FOREGROUNDS) {
out[key] = ensureContrast(out[key], bg, SOFT_MIN_CONTRAST)
}
for (const key of ADAPTIVE_BACKGROUNDS) {
const luminance = hexLuminance(out[key])
if (luminance === null) {
continue
}
if (isLight ? luminance < LIGHT_BG_MIN_LUMINANCE : luminance > DARK_BG_MAX_LUMINANCE) {
out[key] = base[key]
}
}
return out
}
/** The background hex adaptation measures contrast against: the OSC-11
* answer when known (cached in HERMES_TUI_BACKGROUND), else the mode's
* assumed pole. */
function referenceBackground(isLight: boolean, env: NodeJS.ProcessEnv = process.env): string {
const cached = (env.HERMES_TUI_BACKGROUND ?? '').trim()
if (cached && backgroundLuminance(cached) !== null) {
return cached.startsWith('#') ? cached : `#${cached}`
}
return isLight ? '#ffffff' : '#101014'
}
const TRUE_RE = /^(?:1|true|yes|on)$/
const FALSE_RE = /^(?:0|false|no|off)$/
@ -508,6 +655,19 @@ export const DEFAULT_THEME: Theme = normalizeThemeForAnsiLightTerminal(
DEFAULT_LIGHT_MODE
)
/**
* The skinless theme for the CURRENT light-mode signals. Unlike the frozen
* module-load DEFAULT_THEME, this re-reads the environment so it picks up
* the OSC-11 background answer cached into HERMES_TUI_BACKGROUND after
* startup. Used when the terminal background arrives before (or without) a
* gateway skin.
*/
export function defaultThemeForCurrentBackground(env: NodeJS.ProcessEnv = process.env): Theme {
const isLight = detectLightMode(env)
return normalizeThemeForAnsiLightTerminal(isLight ? LIGHT_THEME : DARK_THEME, env, isLight)
}
// ── Skin → Theme ─────────────────────────────────────────────────────
export function fromSkin(
@ -518,7 +678,13 @@ export function fromSkin(
toolPrefix = '',
helpHeader = ''
): Theme {
const d = DEFAULT_THEME
// Live detection (not the module-load snapshot): by the time the gateway
// skin arrives, the OSC-11 background probe has usually answered and cached
// itself into HERMES_TUI_BACKGROUND, so the fallback base — every color key
// the skin does NOT define — matches the actual terminal background instead
// of assuming dark. See #applySkin / syncThemeToTerminalBackground.
const isLight = detectLightMode()
const d = isLight ? LIGHT_THEME : DARK_THEME
const c = (k: string) => colors[k]
const hasSkinColors = Object.keys(colors).length > 0
@ -534,45 +700,54 @@ export function fromSkin(
const completionMetaBg = c('completion_menu_meta_bg') ?? completionBg
const completionMetaCurrentBg = c('completion_menu_meta_current_bg') ?? completionCurrentBg
const assembled: ThemeColors = {
primary: c('ui_primary') ?? c('banner_title') ?? d.color.primary,
accent,
border: c('ui_border') ?? c('banner_border') ?? d.color.border,
text: c('ui_text') ?? c('banner_text') ?? d.color.text,
muted,
completionBg,
completionCurrentBg,
completionMetaBg,
completionMetaCurrentBg,
label: c('ui_label') ?? d.color.label,
ok: c('ui_ok') ?? d.color.ok,
error: c('ui_error') ?? d.color.error,
warn: c('ui_warn') ?? d.color.warn,
prompt: c('prompt') ?? c('banner_text') ?? d.color.prompt,
sessionLabel: c('session_label') ?? muted,
sessionBorder: c('session_border') ?? muted,
statusBg: c('status_bar_bg') ?? d.color.statusBg,
statusFg: c('status_bar_text') ?? d.color.statusFg,
statusGood: c('status_bar_good') ?? c('ui_ok') ?? d.color.statusGood,
statusWarn: c('status_bar_warn') ?? c('ui_warn') ?? d.color.statusWarn,
statusBad: c('status_bar_bad') ?? d.color.statusBad,
statusCritical: c('status_bar_critical') ?? d.color.statusCritical,
selectionBg:
c('selection_bg') ??
c('completion_menu_current_bg') ??
(hasSkinColors ? completionCurrentBg : d.color.selectionBg),
diffAdded: d.color.diffAdded,
diffRemoved: d.color.diffRemoved,
diffAddedWord: d.color.diffAddedWord,
diffRemovedWord: d.color.diffRemovedWord,
shellDollar: c('shell_dollar') ?? d.color.shellDollar
}
// Two exclusive readability strategies: ANSI-limited light Apple Terminal
// keeps its bespoke ansi256 normalization (below) byte-for-byte; every
// truecolor terminal gets contrast enforcement against the real background.
const adapted = shouldNormalizeAnsiLightTheme(process.env, isLight)
? assembled
: adaptColorsToBackground(assembled, isLight, d.color, referenceBackground(isLight))
return normalizeThemeForAnsiLightTerminal(
{
color: {
primary: c('ui_primary') ?? c('banner_title') ?? d.color.primary,
accent,
border: c('ui_border') ?? c('banner_border') ?? d.color.border,
text: c('ui_text') ?? c('banner_text') ?? d.color.text,
muted,
completionBg,
completionCurrentBg,
completionMetaBg,
completionMetaCurrentBg,
label: c('ui_label') ?? d.color.label,
ok: c('ui_ok') ?? d.color.ok,
error: c('ui_error') ?? d.color.error,
warn: c('ui_warn') ?? d.color.warn,
prompt: c('prompt') ?? c('banner_text') ?? d.color.prompt,
sessionLabel: c('session_label') ?? muted,
sessionBorder: c('session_border') ?? muted,
statusBg: d.color.statusBg,
statusFg: d.color.statusFg,
statusGood: c('ui_ok') ?? d.color.statusGood,
statusWarn: c('ui_warn') ?? d.color.statusWarn,
statusBad: d.color.statusBad,
statusCritical: d.color.statusCritical,
selectionBg:
c('selection_bg') ??
c('completion_menu_current_bg') ??
(hasSkinColors ? completionCurrentBg : d.color.selectionBg),
diffAdded: d.color.diffAdded,
diffRemoved: d.color.diffRemoved,
diffAddedWord: d.color.diffAddedWord,
diffRemovedWord: d.color.diffRemovedWord,
shellDollar: c('shell_dollar') ?? d.color.shellDollar
},
color: adapted,
brand: {
name: branding.agent_name ?? d.brand.name,
@ -588,6 +763,6 @@ export function fromSkin(
bannerHero
},
process.env,
DEFAULT_LIGHT_MODE
isLight
)
}

View file

@ -107,6 +107,9 @@ declare module '@hermes/ink' {
export const TextInput: React.ComponentType<any>
export const stringWidth: (s: string) => number
export function isXtermJs(): boolean
export function onTerminalBackground(listener: (hex: string) => void): void
export function terminalBackgroundHex(): string | undefined
export function parseOscColor(data: string): string | undefined
export type ScrollFastPathStats = {
captured: number