From cd05498e2c72f361ece20ad5c8bfa74cc885e95a Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Jul 2026 17:36:18 -0500 Subject: [PATCH] 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. --- hermes_cli/skin_engine.py | 406 +++++++++++++++- package-lock.json | 442 ------------------ tests/hermes_cli/test_skin_engine.py | 7 +- tests/hermes_cli/test_skin_palettes.py | 196 ++++++++ tui_gateway/server.py | 17 + .../packages/hermes-ink/src/entry-exports.ts | 2 +- .../hermes-ink/src/ink/components/App.tsx | 32 +- .../src/ink/osc-response-chain.test.ts | 76 +++ .../src/ink/terminal-background.test.ts | 78 ++++ .../packages/hermes-ink/src/ink/terminal.ts | 98 ++++ .../__tests__/activeSessionSwitcher.test.ts | 13 +- .../createGatewayEventHandler.test.ts | 20 + ui-tui/src/__tests__/theme.test.ts | 123 ++++- ui-tui/src/app/createGatewayEventHandler.ts | 108 ++++- ui-tui/src/app/slash/commands/debug.ts | 29 ++ ui-tui/src/app/slash/commands/session.ts | 26 ++ ui-tui/src/app/useConfigSync.ts | 3 + .../src/components/activeSessionSwitcher.tsx | 8 +- ui-tui/src/components/appLayout.tsx | 1 + ui-tui/src/components/appOverlays.tsx | 15 +- ui-tui/src/components/overlayPrimitives.tsx | 21 +- ui-tui/src/components/textInput.tsx | 35 +- ui-tui/src/gatewayTypes.ts | 7 + ui-tui/src/theme.ts | 253 ++++++++-- ui-tui/src/types/hermes-ink.d.ts | 3 + 25 files changed, 1467 insertions(+), 552 deletions(-) create mode 100644 tests/hermes_cli/test_skin_palettes.py create mode 100644 ui-tui/packages/hermes-ink/src/ink/osc-response-chain.test.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/terminal-background.test.ts diff --git a/hermes_cli/skin_engine.py b/hermes_cli/skin_engine.py index 18d92cdd6e7d..58a22ae14a74 100644 --- a/hermes_cli/skin_engine.py +++ b/hermes_cli/skin_engine.py @@ -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", "┊")), diff --git a/package-lock.json b/package-lock.json index 3236f2e7310a..177ffa33fe57 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/tests/hermes_cli/test_skin_engine.py b/tests/hermes_cli/test_skin_engine.py index ba4d4c4ce16e..0ae10660d48b 100644 --- a/tests/hermes_cli/test_skin_engine.py +++ b/tests/hermes_cli/test_skin_engine.py @@ -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" diff --git a/tests/hermes_cli/test_skin_palettes.py b/tests/hermes_cli/test_skin_palettes.py new file mode 100644 index 000000000000..90290e38850d --- /dev/null +++ b/tests/hermes_cli/test_skin_palettes.py @@ -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) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index e13f34910477..7941481b0bed 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -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 = ( diff --git a/ui-tui/packages/hermes-ink/src/entry-exports.ts b/ui-tui/packages/hermes-ink/src/entry-exports.ts index aaa849506ae8..57968a60e630 100644 --- a/ui-tui/packages/hermes-ink/src/entry-exports.ts +++ b/ui-tui/packages/hermes-ink/src/entry-exports.ts @@ -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' diff --git a/ui-tui/packages/hermes-ink/src/ink/components/App.tsx b/ui-tui/packages/hermes-ink/src/ink/components/App.tsx index 68af579b6e0e..8602a5ba5e43 100644 --- a/ui-tui/packages/hermes-ink/src/ink/components/App.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/components/App.tsx @@ -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 { // 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. diff --git a/ui-tui/packages/hermes-ink/src/ink/osc-response-chain.test.ts b/ui-tui/packages/hermes-ink/src/ink/osc-response-chain.test.ts new file mode 100644 index 000000000000..ab4dddfea392 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/osc-response-chain.test.ts @@ -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) + }) +}) diff --git a/ui-tui/packages/hermes-ink/src/ink/terminal-background.test.ts b/ui-tui/packages/hermes-ink/src/ink/terminal-background.test.ts new file mode 100644 index 000000000000..0cd1d0a9fef1 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/terminal-background.test.ts @@ -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') + }) +}) diff --git a/ui-tui/packages/hermes-ink/src/ink/terminal.ts b/ui-tui/packages/hermes-ink/src/ink/terminal.ts index 16e30e5e35e9..e3318e0fded4 100644 --- a/ui-tui/packages/hermes-ink/src/ink/terminal.ts +++ b/ui-tui/packages/hermes-ink/src/ink/terminal.ts @@ -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+ // disambiguation. We previously enabled unconditionally (#23350), assuming diff --git a/ui-tui/src/__tests__/activeSessionSwitcher.test.ts b/ui-tui/src/__tests__/activeSessionSwitcher.test.ts index d598f6a834c6..175ee05bfc45 100644 --- a/ui-tui/src/__tests__/activeSessionSwitcher.test.ts +++ b/ui-tui/src/__tests__/activeSessionSwitcher.test.ts @@ -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', () => { diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index daf8617c3091..ff584695a8f2 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -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() diff --git a/ui-tui/src/__tests__/theme.test.ts b/ui-tui/src/__tests__/theme.test.ts index 6e356ab3a954..8e75a7861e2a 100644 --- a/ui-tui/src/__tests__/theme.test.ts +++ b/ui-tui/src/__tests__/theme.test.ts @@ -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) }) }) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 4906290ac4c0..bcf70f0f577f 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -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 diff --git a/ui-tui/src/app/slash/commands/debug.ts b/ui-tui/src/app/slash/commands/debug.ts index 868414a66d04..047f9798789b 100644 --- a/ui-tui/src/app/slash/commands/debug.ts +++ b/ui-tui/src/app/slash/commands/debug.ts @@ -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', diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts index e2092a14b745..df3c7b7ffa2e 100644 --- a/ui-tui/src/app/slash/commands/session.ts +++ b/ui-tui/src/app/slash/commands/session.ts @@ -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('config.get', { key: 'theme' }) + .then(ctx.guarded(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('config.set', { key: 'theme', value }) + .then(ctx.guarded(r => r.value !== undefined && ctx.transcript.sys(`theme → ${value}`))) + } + }, + { help: 'switch theme skin (fires skin.changed)', name: 'skin', diff --git a/ui-tui/src/app/useConfigSync.ts b/ui-tui/src/app/useConfigSync.ts index 4e08475afab3..074e5b739197 100644 --- a/ui-tui/src/app/useConfigSync.ts +++ b/ui-tui/src/app/useConfigSync.ts @@ -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 diff --git a/ui-tui/src/components/activeSessionSwitcher.tsx b/ui-tui/src/components/activeSessionSwitcher.tsx index e4ca9ee08509..34e60d7d4f42 100644 --- a/ui-tui/src/components/activeSessionSwitcher.tsx +++ b/ui-tui/src/components/activeSessionSwitcher.tsx @@ -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) => diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 3810d5a0da1c..fa3e4952dc3f 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -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} /> diff --git a/ui-tui/src/components/appOverlays.tsx b/ui-tui/src/components/appOverlays.tsx index 4c117be263b6..60e4bf248584 100644 --- a/ui-tui/src/components/appOverlays.tsx +++ b/ui-tui/src/components/appOverlays.tsx @@ -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: () => ( + {/* 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. */} {completions.slice(start, start + viewportSize).map((item, i) => { const active = start + i === compIdx + const row = listRowStyle(theme, active) return ( {item.meta ? ( - + {' '} {item.meta} diff --git a/ui-tui/src/components/overlayPrimitives.tsx b/ui-tui/src/components/overlayPrimitives.tsx index 995a4d85c207..d24fa9fc0e74 100644 --- a/ui-tui/src/components/overlayPrimitives.tsx +++ b/ui-tui/src/components/overlayPrimitives.tsx @@ -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 ( - + {active ? '▸ ' : ' '} {index}. {label} diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 9fed82096d25..3ac29d368edd 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -43,6 +43,21 @@ type MinimalEnv = Record 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 } diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index f6b3787a10de..521161840618 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -7,7 +7,11 @@ export interface GatewaySkin { banner_logo?: string branding?: Record colors?: Record + /** Hand-tuned palette for dark terminals (light-authored skins). */ + dark_colors?: Record help_header?: string + /** Hand-tuned palette for light terminals (dark-authored skins). */ + light_colors?: Record 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 { diff --git a/ui-tui/src/theme.ts b/ui-tui/src/theme.ts index 8c604df8a0f6..850fcca4c7ca 100644 --- a/ui-tui/src/theme.ts +++ b/ui-tui/src/theme.ts @@ -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 ) } diff --git a/ui-tui/src/types/hermes-ink.d.ts b/ui-tui/src/types/hermes-ink.d.ts index 034e04cf6da1..ea73d338d421 100644 --- a/ui-tui/src/types/hermes-ink.d.ts +++ b/ui-tui/src/types/hermes-ink.d.ts @@ -107,6 +107,9 @@ declare module '@hermes/ink' { export const TextInput: React.ComponentType 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