mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-22 16:25:58 +00:00
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:
parent
4a129af709
commit
cd05498e2c
25 changed files with 1467 additions and 552 deletions
|
|
@ -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
442
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
196
tests/hermes_cli/test_skin_palettes.py
Normal file
196
tests/hermes_cli/test_skin_palettes.py
Normal 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)
|
||||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 (1–21). 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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
3
ui-tui/src/types/hermes-ink.d.ts
vendored
3
ui-tui/src/types/hermes-ink.d.ts
vendored
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue