Terminal light background readability and theme detection
NousResearch/hermes-agent · cluster 4807 · apps/desktop
Data as of
8 open items. Fix merged in #9461. Check it covers your case.
The short answer
- Known?
- Yes. 32 related items grouped here, 6 issues already closed.
- Fix event
- A fix was merged to main in PR #9461. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #13103. That is separate from the fix event. - Still open
- 5 issues and 3 PRs.
Next step: read #9461, then check whether your report is one of the 5 still open.
Read #9461Pick up a Lab taskThese issues and pull requests concern Hermes TUI and CLI text being unreadable on light or cream terminal backgrounds, and efforts to improve auto-detection of light terminals, fix unreliable COLORFGBG inheritance (especially in Zed), and add light-mode skins and color adjustments.
Open issues5
- Issue#31143Remote/Docker deployments: skin selection should auto-set TUI light/dark mode base
- Issue#47210[Feature]: Hermes CLI does not auto-detect light terminal backgrounds in Ghostty, breaks dark-themed skins
- Issue#58038[Feature]: QR code color-inversion detection for dark-mode terminals (WhatsApp pairing)
- Issue#59751[Bug]: Hermes .app TUI agent reply text unreadable on MacBook Air M2 15.3" Retina
- Issue#61536[Bug]: Zed dark themes: `/skin default` is rendered as light-mode colors because Hermes trusts inherited COLORFGBG
Open PRs3
Fixes on record
-
#9461 feat(skin): add light-mode skins + skin-aware completion menus
files:
cli.py,docs/skins/example-skin.yaml,hermes_cli/skin_engine.py,tests/hermes_cli/test_skin_engine.py,website/docs/user-guide/features/skins.mdShow the diff
-
cli.py@@ -988,19 +988,19 @@ def _prune_orphaned_branches(repo_root: str) -> None: # ANSI building blocks for conversation display _ACCENT_ANSI_DEFAULT = "\033[1;38;2;255;215;0m" # True-color #FFD700 bold — fallback _BOLD = "\033[1m" -_DIM = "\033[2m" _RST = "\033[0m" -def _hex_to_ansi_bold(hex_color: str) -> str: - """Convert a hex color like '#268bd2' to a bold true-color ANSI escape.""" +def _hex_to_ansi(hex_color: str, *, bold: bool = False) -> str: + """Convert a hex color like '#268bd2' to a true-color ANSI escape.""" try: r = int(hex_color[1:3], 16) g = int(hex_color[3:5], 16) b = int(hex_color[5:7], 16) - return f"\033[1;38;2;{r};{g};{b}m" + prefix = "1;" if bold else "" + return f"\033[{prefix}38;2;{r};{g};{b}m" except (ValueError, IndexError): - return _ACCENT_ANSI_DEFAULT + return _ACCENT_ANSI_DEFAULT if bold else "\033[38;2;184;134;11m" class _SkinAwareAnsi: @@ -1010,20 +1010,22 @@ class _SkinAwareAnsi: force re-resolution after a ``/skin`` switch. """ - def __init__(self, skin_key: str, fallback_hex: str = "#FFD700"): + def __init__(self, skin_key: str, fallback_hex: str = "#FFD700", *, bold: bool = False): self._skin_key = skin_key self._fallback_hex = fallback_hex + self._bold = bold self._cached: str | None = None def __str__(self) -> str: if self._cached is None: try: from hermes_cli.skin_engine import get_active_skin - self._cached = _hex_to_ansi_bold( - get_active_skin().get_color(self._skin_key, self._fallback_hex) + self._cached = _hex_to_ansi( + get_active_skin().get_color(self._skin_key, self._fallback_hex), + bold=self._bold, ) except Exception: - self._cached = _hex_to_ansi_bold(self._fallback_hex) + self._cached = _hex_to_ansi(self._fallback_hex, bold=self._bold) return self._cached def __add__(self, other: str) -> str: @@ -1037,7 +1039,8 @@ def reset(self) -> None: self._cached = None -_ACCENT = _SkinAwareAnsi("response_border", "#FFD700") +_ACCENT = _SkinAwareAnsi("response_border", "#FFD700", bold=True) +_DIM = _SkinAwareAnsi("banner_dim", "#B8860B") def _accent_hex() -> str: @@ -6156,6 +6159,7 @@ def _handle_skin_command(self, cmd: str): set_active_skin(new_skin) _ACCENT.reset() # Re-resolve ANSI color for the new skin + _DIM.reset() # Re-resolve dim/secondary ANSI color for the new skin if save_config_value("display.skin", new_skin): print(f" Skin set to: {new_skin} (saved)") else: -
docs/skins/example-skin.yaml@@ -41,6 +41,14 @@ colors: session_label: "#DAA520" # Session label session_border: "#8B8682" # Session ID dim color + # TUI surfaces + status_bar_bg: "#1a1a2e" # Status / usage bar background + voice_status_bg: "#1a1a2e" # Voice-mode badge background + completion_menu_bg: "#1a1a2e" # Completion list background + completion_menu_current_bg: "#333355" # Active completion row background + completion_menu_meta_bg: "#1a1a2e" # Completion meta column background + completion_menu_meta_current_bg: "#333355" # Active completion meta background + # ── Spinner ───────────────────────────────────────────────────────────────── # Customize the animated spinner shown during API calls and tool execution. spinner: -
hermes_cli/skin_engine.py@@ -32,6 +32,12 @@ response_border: "#FFD700" # Response box border (ANSI) session_label: "#DAA520" # Session label color session_border: "#8B8682" # Session ID dim color + status_bar_bg: "#1a1a2e" # TUI status/usage bar background + voice_status_bg: "#1a1a2e" # TUI voice status background + completion_menu_bg: "#1a1a2e" # Completion menu background + completion_menu_current_bg: "#333355" # Active completion row background + completion_menu_meta_bg: "#1a1a2e" # Completion meta column background + completion_menu_meta_current_bg: "#333355" # Active completion meta background # Spinner: customize the animated spinner during API calls spinner: @@ -87,6 +93,8 @@ - ``ares`` — Crimson/bronze war-god theme with custom spinner wings - ``mono`` — Clean grayscale monochrome - ``slate`` — Cool blue developer-focused theme +- ``daylight`` — Light background theme with dark text and blue accents +- ``warm-lightmode`` — Warm brown/gold text for light terminal backgrounds USER SKINS ========== @@ -304,6 +312,80 @@ def get_branding(self, key: str, fallback: str = "") -> str: }, "tool_prefix": "┊", }, + "daylight": { + "name": "daylight", + "description": "Light theme for bright terminals with dark text and cool blue accents", + "colors": { + "banner_border": "#2563EB", + "banner_title": "#0F172A", + "banner_accent": "#1D4ED8", + "banner_dim": "#475569", + "banner_text": "#111827", + "ui_accent": "#2563EB", + "ui_label": "#0F766E", + "ui_ok": "#15803D", + "ui_error": "#B91C1C", + "ui_warn": "#B45309", + "prompt": "#111827", + "input_rule": "#93C5FD", + "response_border": "#2563EB", + "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", + }, + "spinner": {}, + "branding": { + "agent_name": "Hermes Agent", + "welcome": "Welcome to Hermes Agent! Type your message or /help for commands.", + "goodbye": "Goodbye! ⚕", + "response_label": " ⚕ Hermes ", + "prompt_symbol": "❯ ", + "help_header": "[?] Available Commands", + }, + "tool_prefix": "│", + }, + "warm-lightmode": { + "name": "warm-lightmode", + "description": "Warm light mode — dark brown/gold text for light terminal backgrounds", + "colors": { + "banner_border": "#8B6914", + "banner_title": "#5C3D11", + "banner_accent": "#8B4513", + "banner_dim": "#8B7355", + "banner_text": "#2C1810", + "ui_accent": "#8B4513", + "ui_label": "#5C3D11", + "ui_ok": "#2E7D32", + "ui_error": "#C62828", + "ui_warn": "#E65100", + "prompt": "#2C1810", + "input_rule": "#8B6914", + "response_border": "#8B6914", + "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", + }, + "spinner": {}, + "branding": { + "agent_name": "Hermes Agent", + "welcome": "Welcome to Hermes Agent! Type your message or /help for commands.", + "goodbye": "Goodbye! \u2695", + "response_label": " \u2695 Hermes ", + "prompt_symbol": "\u276f ", + "help_header": "(^_^)? Available Commands", + }, + "tool_prefix": "\u250a", + }, "poseidon": { "name": "poseidon", "description": "Ocean-god theme — deep blue and seafoam", @@ -685,6 +767,12 @@ def get_prompt_toolkit_style_overrides() -> Dict[str, str]: label = skin.get_color("ui_label", title) warn = skin.get_color("ui_warn", "#FF8C00") error = skin.get_color("ui_error", "#FF6B6B") + status_bg = skin.get_color("status_bar_bg", "#1a1a2e") + voice_bg = skin.get_color("voice_status_bg", status_bg) + menu_bg = skin.get_color("completion_menu_bg", "#1a1a2e") + menu_current_bg = skin.get_color("completion_menu_current_bg", "#333355") + menu_meta_bg = skin.get_color("completion_menu_meta_bg", menu_bg) + menu_meta_current_bg = skin.get_color("completion_menu_meta_current_bg", menu_current_bg) return { "input-area": prompt, @@ -692,13 +780,20 @@ def get_prompt_toolkit_style_overrides() -> Dict[str, str]: "prompt": prompt, "prompt-working": f"{dim} italic", "hint": f"{dim} italic", + "status-bar": f"bg:{status_bg} {text}", + "status-bar-strong": f"bg:{status_bg} {title} bold", + "status-bar-dim": f"bg:{status_bg} {dim}", + "status-bar-good": f"bg:{status_bg} {skin.get_color('ui_ok', '#8FBC8F')} bold", + "status-bar-warn": f"bg:{status_bg} {warn} bold", + "status-bar-bad": f"bg:{status_bg} {skin.get_color('banner_accent', warn)} bold", + "status-bar-critical": f"bg:{status_bg} {error} bold", "input-rule": input_rule, "image-badge": f"{label} bold", - "completion-menu": f"bg:#1a1a2e {text}", - "completion-menu.completion": f"bg:#1a1a2e {text}", - "completion-menu.completion.current": f"bg:#333355 {title}", - "completion-menu.meta.completion": f"bg:#1a1a2e {dim}", - "completion-menu.meta.completion.current": f"bg:#333355 {label}", + "completion-menu": f"bg:{menu_bg} {text}", + "completion-menu.completion": f"bg:{menu_bg} {text}", + "completion-menu.completion.current": f"bg:{menu_current_bg} {title}", + "completion-menu.meta.completion": f"bg:{menu_meta_bg} {dim}", + "completion-menu.meta.completion.current": f"bg:{menu_meta_current_bg} {label}", "clarify-border": input_rule, "clarify-title": f"{title} bold", "clarify-question": f"{text} bold", @@ -716,4 +811,6 @@ def get_prompt_toolkit_style_overrides() -> Dict[str, str]: "approval-cmd": f"{dim} italic", "approval-choice": dim, "approval-selected": f"{title} bold", + "voice-status": f"bg:{voice_bg} {label}", + "voice-status-recording": f"bg:{voice_bg} {error} bold", } -
tests/hermes_cli/test_skin_engine.py@@ -78,6 +78,28 @@ def test_slate_skin_loads(self): assert skin.name == "slate" assert skin.get_color("banner_title") == "#7eb8f6" + def test_daylight_skin_loads(self): + from hermes_cli.skin_engine import load_skin + + skin = load_skin("daylight") + assert skin.name == "daylight" + assert skin.tool_prefix == "│" + assert skin.get_color("banner_title") == "#0F172A" + assert skin.get_color("status_bar_bg") == "#E5EDF8" + assert skin.get_color("voice_status_bg") == "#E5EDF8" + assert skin.get_color("completion_menu_bg") == "#F8FAFC" + assert skin.get_color("completion_menu_current_bg") == "#DBEAFE" + assert skin.get_color("completion_menu_meta_bg") == "#EEF2FF" + assert skin.get_color("completion_menu_meta_current_bg") == "#BFDBFE" + + def test_warm_lightmode_skin_loads(self): + from hermes_cli.skin_engine import load_skin + + skin = load_skin("warm-lightmode") + assert skin.name == "warm-lightmode" + assert skin.get_color("banner_text") == "#2C1810" + assert skin.get_color("completion_menu_bg") == "#F5EFE0" + def test_unknown_skin_falls_back_to_default(self): from hermes_cli.skin_engine import load_skin skin = load_skin("nonexistent_skin_xyz") @@ -114,6 +136,8 @@ def test_list_skins_includes_builtins(self): assert "ares" in names assert "mono" in names assert "slate" in names + assert "daylight" in names + assert "warm-lightmode" in names for s in skins: assert "source" in s assert s["source"] == "builtin" @@ -242,6 +266,15 @@ def test_prompt_toolkit_style_overrides_cover_tui_classes(self): "completion-menu.completion.current", "completion-menu.meta.completion", "completion-menu.meta.completion.current", + "status-bar", + "status-bar-strong", + "status-bar-dim", + "status-bar-good", + "status-bar-warn", + "status-bar-bad", + "status-bar-critical", + "voice-status", + "voice-status-recording", "clarify-border", "clarify-title", "clarify-question", @@ -277,3 +310,9 @@ def test_prompt_toolkit_style_overrides_use_skin_colors(self): assert overrides["clarify-title"] == f"{skin.get_color('banner_title')} bold" assert overrides["sudo-prompt"] == f"{skin.get_color('ui_error')} bold" assert overrides["approval-title"] == f"{skin.get_color('ui_warn')} bold" + + set_active_skin("daylight") + skin = get_active_skin() + overrides = get_prompt_toolkit_style_overrides() + assert overrides["status-bar"] == f"bg:{skin.get_color('status_bar_bg')} {skin.get_color('banner_text')}" + assert overrides["voice-status"] == f"bg:{skin.get_color('voice_status_bg')} {skin.get_color('ui_label')}" -
website/docs/user-guide/features/skins.md@@ -36,6 +36,8 @@ display: | `ares` | War-god theme — crimson and bronze | `Ares Agent` | Deep crimson borders with bronze accents. Aggressive spinner verbs ("forging", "marching", "tempering steel"). Custom sword-and-shield ASCII art banner. | | `mono` | Monochrome — clean grayscale | `Hermes Agent` | All grays — no color. Borders are `#555555`, text is `#c9d1d9`. Ideal for minimal terminal setups or screen recordings. | | `slate` | Cool blue — developer-focused | `Hermes Agent` | Royal blue borders (`#4169e1`), soft blue text. Calm and professional. No custom spinner — uses default faces. | +| `daylight` | Light theme for bright terminals with dark text and cool blue accents | `Hermes Agent` | Designed for white or bright terminals. Dark slate text with blue borders, pale status surfaces, and a light completion menu that stays readable in light terminal profiles. | +| `warm-lightmode` | Warm brown/gold text for light terminal backgrounds | `Hermes Agent` | Warm parchment tones for light terminals. Dark brown text with saddle-brown accents, cream-colored status surfaces. An earthy alternative to the cooler daylight theme. | | `poseidon` | Ocean-god theme — deep blue and seafoam | `Poseidon Agent` | Deep blue to seafoam gradient. Ocean-themed spinners ("charting currents", "sounding the depth"). Trident ASCII art banner. | | `sisyphus` | Sisyphean theme — austere grayscale with persistence | `Sisyphus Agent` | Light grays with stark contrast. Boulder-themed spinners ("pushing uphill", "resetting the boulder", "enduring the loop"). Boulder-and-hill ASCII art banner. | | `charizard` | Volcanic theme — burnt orange and ember | `Charizard Agent` | Warm burnt orange to ember gradient. Fire-themed spinners ("banking into the draft", "measuring burn"). Dragon-silhouette ASCII art banner. | @@ -63,6 +65,12 @@ Controls all color values throughout the CLI. Values are hex color strings. | `response_border` | Border around the agent's response box (ANSI escape) | `#FFD700` | | `session_label` | Session label color | `#DAA520` | | `session_border` | Session ID dim border color | `#8B8682` | +| `status_bar_bg` | Background color for the TUI status / usage bar | `#1a1a2e` | +| `voice_status_bg` | Background color for the voice-mode status badge | `#1a1a2e` | +| `completion_menu_bg` | Background color for the completion menu list | `#1a1a2e` | +| `completion_menu_current_bg` | Background color for the active completion row | `#333355` | +| `completion_menu_meta_bg` | Background color for the completion meta column | `#1a1a2e` | +| `completion_menu_meta_current_bg` | Background color for the active completion meta column | `#333355` | ### Spinner (`spinner:`) @@ -129,6 +137,12 @@ colors: response_border: "#FFD700" session_label: "#DAA520" session_border: "#8B8682" + status_bar_bg: "#1a1a2e" + voice_status_bg: "#1a1a2e" + completion_menu_bg: "#1a1a2e" + completion_menu_current_bg: "#333355" + completion_menu_meta_bg: "#1a1a2e" + completion_menu_meta_current_bg: "#333355" spinner: waiting_faces:
-
-
#13103 fix(tui): theme-driven update-behind banner + auto-detect light terminals (#11300)
files:
ui-tui/src/__tests__/theme.test.ts,ui-tui/src/components/branding.tsx,ui-tui/src/theme.tsShow the diff
-
ui-tui/src/__tests__/theme.test.ts@@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { DARK_THEME, DEFAULT_THEME, fromSkin, LIGHT_THEME } from '../theme.js' +import { DARK_THEME, DEFAULT_THEME, detectLightMode, fromSkin, LIGHT_THEME } from '../theme.js' describe('DEFAULT_THEME', () => { it('has brand defaults', () => { @@ -30,11 +30,37 @@ describe('LIGHT_THEME', () => { }) describe('DEFAULT_THEME aliasing', () => { - it('defaults to DARK_THEME when HERMES_TUI_LIGHT is unset', () => { + it('defaults to DARK_THEME when nothing signals light', () => { expect(DEFAULT_THEME).toBe(DARK_THEME) }) }) +describe('detectLightMode', () => { + it('returns false on empty env', () => { + expect(detectLightMode({})).toBe(false) + }) + + it('honors HERMES_TUI_LIGHT on/off', () => { + expect(detectLightMode({ HERMES_TUI_LIGHT: '1' })).toBe(true) + expect(detectLightMode({ HERMES_TUI_LIGHT: 'true' })).toBe(true) + expect(detectLightMode({ HERMES_TUI_LIGHT: 'on' })).toBe(true) + expect(detectLightMode({ HERMES_TUI_LIGHT: '0' })).toBe(false) + expect(detectLightMode({ HERMES_TUI_LIGHT: 'off' })).toBe(false) + }) + + it('sniffs COLORFGBG bg slots 7 and 15 as light (#11300)', () => { + expect(detectLightMode({ COLORFGBG: '0;15' })).toBe(true) + expect(detectLightMode({ COLORFGBG: '0;default;15' })).toBe(true) + expect(detectLightMode({ COLORFGBG: '0;7' })).toBe(true) + expect(detectLightMode({ COLORFGBG: '15;0' })).toBe(false) + expect(detectLightMode({ COLORFGBG: '7;default;0' })).toBe(false) + }) + + it('lets HERMES_TUI_LIGHT=0 override a light COLORFGBG', () => { + expect(detectLightMode({ COLORFGBG: '0;15', HERMES_TUI_LIGHT: '0' })).toBe(false) + }) +}) + describe('fromSkin', () => { it('overrides banner colors', () => { expect(fromSkin({ banner_title: '#FF0000' }, {}).color.gold).toBe('#FF0000') -
ui-tui/src/components/branding.tsx@@ -161,16 +161,16 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) { </Text> {typeof info.update_behind === 'number' && info.update_behind > 0 && ( - <Text bold color="yellow"> + <Text bold color={t.color.warn}> ! {info.update_behind} {info.update_behind === 1 ? 'commit' : 'commits'} behind - <Text bold={false} color="yellow" dimColor> + <Text bold={false} color={t.color.warn} dimColor> {' '} - run{' '} </Text> - <Text bold color="yellow"> + <Text bold color={t.color.warn}> {info.update_command || 'hermes update'} </Text> - <Text bold={false} color="yellow" dimColor> + <Text bold={false} color={t.color.warn} dimColor> {' '} to update </Text> -
ui-tui/src/theme.ts@@ -171,9 +171,26 @@ export const LIGHT_THEME: Theme = { bannerHero: '' } -const LIGHT_MODE = /^(?:1|true|yes|on)$/i.test((process.env.HERMES_TUI_LIGHT ?? '').trim()) +// Pick light vs dark. Explicit `HERMES_TUI_LIGHT` wins; otherwise sniff +// `COLORFGBG` (set by XFCE Terminal, rxvt, Terminal.app, etc.) — last field is the +// background ANSI index; 7/15 are the "white" slots most light themes emit (#11300). +export function detectLightMode(env: NodeJS.ProcessEnv = process.env): boolean { + const explicit = (env.HERMES_TUI_LIGHT ?? '').trim().toLowerCase() + + if (/^(?:1|true|yes|on)$/.test(explicit)) { + return true + } + + if (/^(?:0|false|no|off)$/.test(explicit)) { + return false + } + + const bg = Number((env.COLORFGBG ?? '').trim().split(';').at(-1)) + + return bg === 7 || bg === 15 +} -export const DEFAULT_THEME: Theme = LIGHT_MODE ? LIGHT_THEME : DARK_THEME +export const DEFAULT_THEME: Theme = detectLightMode() ? LIGHT_THEME : DARK_THEME // ── Skin → Theme ─────────────────────────────────────────────────────
-
-
#17113 feat(tui): expand light-terminal auto-detection (HERMES_TUI_THEME, background hex)
files:
ui-tui/src/__tests__/theme.test.ts,ui-tui/src/theme.tsShow the diff
-
ui-tui/src/__tests__/theme.test.ts@@ -1,46 +1,90 @@ -import { describe, expect, it } from 'vitest' - -import { DARK_THEME, DEFAULT_THEME, detectLightMode, fromSkin, LIGHT_THEME } from '../theme.js' +import { afterEach, describe, expect, it, vi } from 'vitest' + +// `theme.js` reads `process.env` at module-load to compute DEFAULT_THEME, +// and `fromSkin` closes over DEFAULT_THEME. A developer shell with +// HERMES_TUI_THEME=light (or HERMES_TUI_BACKGROUND set to something +// bright) would flip the base and turn these assertions into a local- +// only failure. We sterilize the relevant env vars + dynamically +// import the module fresh so EVERY symbol that closes over the env +// (DEFAULT_THEME, DARK_THEME, LIGHT_THEME, fromSkin) is loaded against +// a known-empty environment. +// +// `detectLightMode` takes env as an explicit arg, so it's safe to import +// statically — but we stay consistent and dynamic-import it too. +const RELEVANT_ENV = [ + 'HERMES_TUI_LIGHT', + 'HERMES_TUI_THEME', + 'HERMES_TUI_BACKGROUND', + 'COLORFGBG', + 'TERM_PROGRAM', +] as const + +async function importThemeWithCleanEnv() { + for (const key of RELEVANT_ENV) { + vi.stubEnv(key, '') + } + vi.resetModules() + return import('../theme.js') +} + +afterEach(() => { + vi.unstubAllEnvs() + vi.resetModules() +}) describe('DEFAULT_THEME', () => { - it('has brand defaults', () => { + it('has brand defaults', async () => { + const { DEFAULT_THEME } = await importThemeWithCleanEnv() + expect(DEFAULT_THEME.brand.name).toBe('Hermes Agent') expect(DEFAULT_THEME.brand.prompt).toBe('❯') expect(DEFAULT_THEME.brand.tool).toBe('┊') }) - it('has color palette', () => { + it('has color palette', async () => { + const { DEFAULT_THEME } = await importThemeWithCleanEnv() + expect(DEFAULT_THEME.color.gold).toBe('#FFD700') expect(DEFAULT_THEME.color.error).toBe('#ef5350') }) }) describe('LIGHT_THEME', () => { - it('avoids bright-yellow accents unreadable on white backgrounds (#11300)', () => { + it('avoids bright-yellow accents unreadable on white backgrounds (#11300)', async () => { + const { LIGHT_THEME } = await importThemeWithCleanEnv() + expect(LIGHT_THEME.color.gold).not.toBe('#FFD700') expect(LIGHT_THEME.color.amber).not.toBe('#FFBF00') expect(LIGHT_THEME.color.dim).not.toBe('#B8860B') expect(LIGHT_THEME.color.statusWarn).not.toBe('#FFD700') }) - it('keeps the same shape as DARK_THEME', () => { + it('keeps the same shape as DARK_THEME', async () => { + const { DARK_THEME, LIGHT_THEME } = await importThemeWithCleanEnv() + expect(Object.keys(LIGHT_THEME.color).sort()).toEqual(Object.keys(DARK_THEME.color).sort()) expect(LIGHT_THEME.brand).toEqual(DARK_THEME.brand) }) }) describe('DEFAULT_THEME aliasing', () => { - it('defaults to DARK_THEME when nothing signals light', () => { - expect(DEFAULT_THEME).toBe(DARK_THEME) + it('defaults to DARK_THEME when nothing signals light', async () => { + const { DEFAULT_THEME, DARK_THEME: DARK } = await importThemeWithCleanEnv() + + expect(DEFAULT_THEME).toBe(DARK) }) }) describe('detectLightMode', () => { - it('returns false on empty env', () => { + it('returns false on empty env', async () => { + const { detectLightMode } = await importThemeWithCleanEnv() + expect(detectLightMode({})).toBe(false) }) - it('honors HERMES_TUI_LIGHT on/off', () => { + it('honors HERMES_TUI_LIGHT on/off', async () => { + const { detectLightMode } = await importThemeWithCleanEnv() + expect(detectLightMode({ HERMES_TUI_LIGHT: '1' })).toBe(true) expect(detectLightMode({ HERMES_TUI_LIGHT: 'true' })).toBe(true) expect(detectLightMode({ HERMES_TUI_LIGHT: 'on' })).toBe(true) @@ -48,7 +92,9 @@ describe('detectLightMode', () => { expect(detectLightMode({ HERMES_TUI_LIGHT: 'off' })).toBe(false) }) - it('sniffs COLORFGBG bg slots 7 and 15 as light (#11300)', () => { + it('sniffs COLORFGBG bg slots 7 and 15 as light (#11300)', async () => { + const { detectLightMode } = await importThemeWithCleanEnv() + expect(detectLightMode({ COLORFGBG: '0;15' })).toBe(true) expect(detectLightMode({ COLORFGBG: '0;default;15' })).toBe(true) expect(detectLightMode({ COLORFGBG: '0;7' })).toBe(true) @@ -56,38 +102,119 @@ describe('detectLightMode', () => { expect(detectLightMode({ COLORFGBG: '7;default;0' })).toBe(false) }) - it('lets HERMES_TUI_LIGHT=0 override a light COLORFGBG', () => { + it('falls through on malformed COLORFGBG with empty/non-numeric trailing field', async () => { + const { detectLightMode } = await importThemeWithCleanEnv() + // `Number('')` is 0, so `'15;'` would have been read as bg=0 + // (authoritative dark) and incorrectly blocked TERM_PROGRAM. + // The strict /^\d+$/ guard makes these fall through instead. + const allowList = new Set(['Apple_Terminal']) + + expect(detectLightMode({ COLORFGBG: '15;', TERM_PROGRAM: 'Apple_Terminal' }, allowList)).toBe(true) + expect(detectLightMode({ COLORFGBG: 'default;default', TERM_PROGRAM: 'Apple_Terminal' }, allowList)).toBe(true) + // Without an allow-list match, fall-through still defaults to dark. + expect(detectLightMode({ COLORFGBG: '15;' })).toBe(false) + }) + + it('lets HERMES_TUI_LIGHT=0 override a light COLORFGBG', async () => { + const { detectLightMode } = await importThemeWithCleanEnv() + expect(detectLightMode({ COLORFGBG: '0;15', HERMES_TUI_LIGHT: '0' })).toBe(false) }) + + it('honors HERMES_TUI_THEME=light/dark as a symmetric explicit override', async () => { + const { detectLightMode } = await importThemeWithCleanEnv() + + expect(detectLightMode({ HERMES_TUI_THEME: 'light' })).toBe(true) + expect(detectLightMode({ HERMES_TUI_THEME: 'dark' })).toBe(false) + expect(detectLightMode({ COLORFGBG: '0;15', HERMES_TUI_THEME: 'dark' })).toBe(false) + expect(detectLightMode({ COLORFGBG: '15;0', HERMES_TUI_THEME: 'light' })).toBe(true) + }) + + it('uses HERMES_TUI_BACKGROUND luminance when COLORFGBG is missing', async () => { + const { detectLightMode } = await importThemeWithCleanEnv() + + expect(detectLightMode({ HERMES_TUI_BACKGROUND: '#ffffff' })).toBe(true) + expect(detectLightMode({ HERMES_TUI_BACKGROUND: '#000000' })).toBe(false) + expect(detectLightMode({ HERMES_TUI_BACKGROUND: '#1e1e1e' })).toBe(false) + // Three-char hex normalises like CSS. + expect(detectLightMode({ HERMES_TUI_BACKGROUND: '#fff' })).toBe(true) + // Garbage falls through to the default-dark path. + expect(detectLightMode({ HERMES_TUI_BACKGROUND: 'not-a-colour' })).toBe(false) + }) + + it('rejects partially-invalid hex instead of silently truncating', async () => { + const { detectLightMode } = await importThemeWithCleanEnv() + // `parseInt('fffgff'.slice(2,4), 16)` would return 15 — the strict + // regex must reject these inputs so they fall through to default- + // dark instead of producing a false-positive light reading. + expect(detectLightMode({ HERMES_TUI_BACKGROUND: '#fffgff' })).toBe(false) + expect(detectLightMode({ HERMES_TUI_BACKGROUND: 'ffggff' })).toBe(false) + expect(detectLightMode({ HERMES_TUI_BACKGROUND: '#xyz' })).toBe(false) + // Wrong length also rejected (no implicit padding/truncation). + expect(detectLightMode({ HERMES_TUI_BACKGROUND: '#fffff' })).toBe(false) + expect(detectLightMode({ HERMES_TUI_BACKGROUND: '#fffffff' })).toBe(false) + }) + + it('treats COLORFGBG as authoritative when present so it dominates the TERM_PROGRAM allow-list', async () => { + const { detectLightMode } = await importThemeWithCleanEnv() + // Inject a light-default allow-list so the precedence test is + // meaningful even though the production allow-list is empty. + const allowList = new Set(['Apple_Terminal']) + + // Sanity: the allow-list alone WOULD turn this terminal light. + expect(detectLightMode({ TERM_PROGRAM: 'Apple_Terminal' }, allowList)).toBe(true) + + // Dark COLORFGBG must beat the allow-list. + expect( + detectLightMode({ COLORFGBG: '15;0', TERM_PROGRAM: 'Apple_Terminal' }, allowList), + ).toBe(false) + }) }) describe('fromSkin', () => { - it('overrides banner colors', () => { + // `fromSkin` closes over DEFAULT_THEME (which is env-derived), so we + // must dynamic-import it after sterilizing env — otherwise an ambient + // HERMES_TUI_THEME=light would flip the base palette and make these + // assertions order-dependent on the developer's shell. + + it('overrides banner colors', async () => { + const { fromSkin } = await importThemeWithCleanEnv() + expect(fromSkin({ banner_title: '#FF0000' }, {}).color.gold).toBe('#FF0000') }) - it('preserves unset colors', () => { + it('preserves unset colors', async () => { + const { DEFAULT_THEME, fromSkin } = await importThemeWithCleanEnv() + expect(fromSkin({ banner_title: '#FF0000' }, {}).color.amber).toBe(DEFAULT_THEME.color.amber) }) - it('overrides branding', () => { + it('overrides branding', async () => { + const { fromSkin } = await importThemeWithCleanEnv() const { brand } = fromSkin({}, { agent_name: 'TestBot', prompt_symbol: '$' }) + expect(brand.name).toBe('TestBot') expect(brand.prompt).toBe('$') }) - it('defaults for empty skin', () => { + it('defaults for empty skin', async () => { + const { DEFAULT_THEME, fromSkin } = await importThemeWithCleanEnv() + expect(fromSkin({}, {}).color).toEqual(DEFAULT_THEME.color) expect(fromSkin({}, {}).brand.icon).toBe(DEFAULT_THEME.brand.icon) }) - it('passes banner logo/hero', () => { + it('passes banner logo/hero', async () => { + const { fromSkin } = await importThemeWithCleanEnv() + expect(fromSkin({}, {}, 'LOGO', 'HERO').bannerLogo).toBe('LOGO') expect(fromSkin({}, {}, 'LOGO', 'HERO').bannerHero).toBe('HERO') }) - it('maps ui_ color keys + cascades to status', () => { + it('maps ui_ color keys + cascades to status', async () => { + const { fromSkin } = await importThemeWithCleanEnv() const { color } = fromSkin({ ui_ok: '#008000' }, {}) + expect(color.ok).toBe('#008000') expect(color.statusGood).toBe('#008000') }) -
ui-tui/src/theme.ts@@ -179,23 +179,129 @@ export const LIGHT_THEME: Theme = { bannerHero: '' } -// Pick light vs dark. Explicit `HERMES_TUI_LIGHT` wins; otherwise sniff -// `COLORFGBG` (set by XFCE Terminal, rxvt, Terminal.app, etc.) — last field is the -// background ANSI index; 7/15 are the "white" slots most light themes emit (#11300). -export function detectLightMode(env: NodeJS.ProcessEnv = process.env): boolean { - const explicit = (env.HERMES_TUI_LIGHT ?? '').trim().toLowerCase() +const TRUE_RE = /^(?:1|true|yes|on)$/ +const FALSE_RE = /^(?:0|false|no|off)$/ + +// Reserved for future TERM_PROGRAM-based heuristics. Empty by default: +// most modern terminals (Ghostty, Warp, iTerm2, Apple_Terminal) ship a +// dark profile out of the box, so guessing wrong here is more annoying +// than missing a light user — light users can always set +// `HERMES_TUI_LIGHT=1` or `HERMES_TUI_THEME=light`. +const LIGHT_DEFAULT_TERM_PROGRAMS = new Set<string>() + +// Best-effort RGB → luminance check. Currently only accepts a 3- or +// 6-digit hex value (with or without a leading `#`); the env var name +// `HERMES_TUI_BACKGROUND` is intentionally generic so a future OSC11 +// query helper can cache its answer there too, but additional formats +// (rgb()/hsl()/named colours) would need explicit parsing here first. +const LUMA_LIGHT_THRESHOLD = 0.6 + +// Strict allow-list: parseInt(..., 16) silently truncates at the first +// non-hex character (e.g. `fffgff` would parse as `fff` and yield a +// false-positive "white" reading), so reject anything that doesn't match +// the canonical 3- or 6-digit shape up front. +const HEX_3_RE = /^[0-9a-f]{3}$/ +const HEX_6_RE = /^[0-9a-f]{6}$/ + +function backgroundLuminance(raw: string): null | number { + const v = raw.trim().toLowerCase() + + if (!v) { + return null + } + + const hex = v.startsWith('#') ? v.slice(1) : v + const rgb = HEX_6_RE.test(hex) + ? [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16)] + : HEX_3_RE.test(hex) + ? [parseInt(hex[0]! + hex[0]!, 16), parseInt(hex[1]! + hex[1]!, 16), parseInt(hex[2]! + hex[2]!, 16)] + : null + + if (!rgb) { + return null + } + + // Rec. 709 luma — close enough for "is this background bright". + return (0.2126 * rgb[0]! + 0.7152 * rgb[1]! + 0.0722 * rgb[2]!) / 255 +} + +// Pick light vs dark with ordered, explainable signals (#11300): +// +// 1. `HERMES_TUI_LIGHT` boolean — `1`/`true`/`yes`/`on` → light; +// `0`/`false`/`no`/`off` → dark. Either explicit value wins +// regardless of any later signal. +// 2. `HERMES_TUI_THEME` named override — `light` / `dark` win over +// every signal below. +// 3. `HERMES_TUI_BACKGROUND` hex hint (3- or 6-digit) — luminance +// ≥ LUMA_LIGHT_THRESHOLD → light. +// 4. `COLORFGBG` last field — XFCE / rxvt / Terminal.app emit +// slot 7 or 15 on light profiles; 0–15 ranges are otherwise +// treated as authoritatively dark so the TERM_PROGRAM +// allow-list below cannot override an explicit dark profile. +// 5. `TERM_PROGRAM` light-default allow-list (currently empty). +// +// Anything we can't decide stays dark — the default Hermes palette +// is the dark one. +export function detectLightMode( + env: NodeJS.ProcessEnv = process.env, + // Injectable so tests can prove the COLORFGBG-over-TERM_PROGRAM + // precedence rule even though the production allow-list is empty. + lightDefaultTermPrograms: ReadonlySet<string> = LIGHT_DEFAULT_TERM_PROGRAMS, +): boolean { + const lightFlag = (env.HERMES_TUI_LIGHT ?? '').trim().toLowerCase() + + if (TRUE_RE.test(lightFlag)) { + return true + } + + if (FALSE_RE.test(lightFlag)) { + return false + } + + const themeFlag = (env.HERMES_TUI_THEME ?? '').trim().toLowerCase() - if (/^(?:1|true|yes|on)$/.test(explicit)) { + if (themeFlag === 'light') { return true } - if (/^(?:0|false|no|off)$/.test(explicit)) { + if (themeFlag === 'dark') { return false } - const bg = Number((env.COLORFGBG ?? '').trim().split(';').at(-1)) + const bgHint = backgroundLuminance(env.HERMES_TUI_BACKGROUND ?? '') + + if (bgHint !== null) { + return bgHint >= LUMA_LIGHT_THRESHOLD + } + + const colorfgbg = (env.COLORFGBG ?? '').trim() + + if (colorfgbg) { + // Validate as a decimal integer before coercing — `Number('')` is 0, + // so a malformed `COLORFGBG='15;'` would otherwise look like an + // authoritative dark slot and incorrectly block the TERM_PROGRAM + // allow-list. Anything that isn't pure digits falls through. + const lastField = colorfgbg.split(';').at(-1) ?? '' + + if (/^\d+$/.test(lastField)) { + const bg = Number(lastField) + + if (bg === 7 || bg === 15) { + return true + } + + // Slots 0–6 and 8–14 are the dark half of the 0–15 ANSI range. + // When COLORFGBG is set we trust it as authoritative — a non-light + // value here shouldn't get overridden by the TERM_PROGRAM allow-list. + if (bg >= 0 && bg < 16) { + return false + } + } + } + + const termProgram = (env.TERM_PROGRAM ?? '').trim() - return bg === 7 || bg === 15 + return lightDefaultTermPrograms.has(termProgram) } export const DEFAULT_THEME: Theme = detectLightMode() ? LIGHT_THEME : DARK_THEME
-
-
#17695 fix(tui): normalize legacy Terminal.app colors
files:
ui-tui/packages/hermes-ink/src/ink/colorize.test.ts,ui-tui/packages/hermes-ink/src/ink/colorize.ts,ui-tui/packages/hermes-ink/src/ink/components/Text.test.ts,ui-tui/packages/hermes-ink/src/ink/components/Text.tsx,ui-tui/src/__tests__/forceTruecolor.test.ts,ui-tui/src/__tests__/theme.test.ts,ui-tui/src/entry.tsx,ui-tui/src/lib/forceTruecolor.ts,ui-tui/src/theme.tsShow the diff
-
ui-tui/packages/hermes-ink/src/ink/colorize.test.ts@@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' + +import { + CHALK_USES_RICH_EIGHT_BIT_DOWNGRADE, + richEightBitColorNumber, + shouldUseRichEightBitDowngradeForLegacyAppleTerminal +} from './colorize.js' + +describe('shouldUseRichEightBitDowngradeForLegacyAppleTerminal', () => { + it('memoizes the current process decision for render hot paths', () => { + expect(typeof CHALK_USES_RICH_EIGHT_BIT_DOWNGRADE).toBe('boolean') + }) + + it('uses Rich-compatible 256-color downgrade on legacy Apple Terminal', () => { + expect( + shouldUseRichEightBitDowngradeForLegacyAppleTerminal({ TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv, 2) + ).toBe(true) + }) + + it('normalizes Apple Terminal names before matching', () => { + expect( + shouldUseRichEightBitDowngradeForLegacyAppleTerminal({ TERM_PROGRAM: ' Apple_Terminal ' } as NodeJS.ProcessEnv, 2) + ).toBe(true) + }) + + it('does not rewrite when Apple Terminal advertises truecolor', () => { + expect( + shouldUseRichEightBitDowngradeForLegacyAppleTerminal( + { COLORTERM: 'truecolor', TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv, + 3 + ) + ).toBe(false) + }) + + it('does not override explicit color environment choices', () => { + expect( + shouldUseRichEightBitDowngradeForLegacyAppleTerminal( + { FORCE_COLOR: '2', TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv, + 2 + ) + ).toBe(false) + expect( + shouldUseRichEightBitDowngradeForLegacyAppleTerminal( + { HERMES_TUI_TRUECOLOR: '1', TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv, + 3 + ) + ).toBe(false) + }) +}) + +describe('richEightBitColorNumber', () => { + it('matches Rich downgrade output for default Hermes skin colors', () => { + expect(richEightBitColorNumber(0xff, 0xd7, 0x00)).toBe(220) + expect(richEightBitColorNumber(0xff, 0xbf, 0x00)).toBe(214) + expect(richEightBitColorNumber(0xcd, 0x7f, 0x32)).toBe(173) + expect(richEightBitColorNumber(0xb8, 0x86, 0x0b)).toBe(136) + expect(richEightBitColorNumber(0xff, 0xf8, 0xdc)).toBe(230) + }) +}) + -
ui-tui/packages/hermes-ink/src/ink/colorize.ts@@ -28,6 +28,39 @@ function boostChalkLevelForXtermJs(): boolean { return false } +export function shouldUseRichEightBitDowngradeForLegacyAppleTerminal( + env: NodeJS.ProcessEnv = process.env, + level = chalk.level +): boolean { + const termProgram = (env.TERM_PROGRAM ?? '').trim() + const truecolorOverride = /^(?:1|true|yes|on)$/i.test((env.HERMES_TUI_TRUECOLOR ?? '').trim()) + const advertisesTruecolor = /^(?:truecolor|24bit)$/i.test((env.COLORTERM ?? '').trim()) + + return termProgram === 'Apple_Terminal' && !truecolorOverride && !advertisesTruecolor && !('FORCE_COLOR' in env) && level === 2 +} + +export function richEightBitColorNumber(red: number, green: number, blue: number): number { + const rn = red / 255 + const gn = green / 255 + const bn = blue / 255 + const max = Math.max(rn, gn, bn) + const min = Math.min(rn, gn, bn) + const lightness = (max + min) / 2 + const saturation = max === min ? 0 : lightness > 0.5 ? (max - min) / (2 - max - min) : (max - min) / (max + min) + + if (saturation < 0.15) { + const gray = Math.round(lightness * 25) + + return gray === 0 ? 16 : gray === 25 ? 231 : 231 + gray + } + + const sixRed = red < 95 ? red / 95 : 1 + (red - 95) / 40 + const sixGreen = green < 95 ? green / 95 : 1 + (green - 95) / 40 + const sixBlue = blue < 95 ? blue / 95 : 1 + (blue - 95) / 40 + + return 16 + 36 * Math.round(sixRed) + 6 * Math.round(sixGreen) + Math.round(sixBlue) +} + /** * tmux parses truecolor SGR (\e[48;2;r;g;bm) into its cell buffer correctly, * but its client-side emitter only re-emits truecolor to the outer terminal if @@ -58,15 +91,17 @@ function clampChalkLevelForTmux(): boolean { } // Computed once at module load — terminal/tmux environment doesn't change mid-session. -// Order matters: boost first so the tmux clamp can re-clamp if tmux is running -// inside a VS Code terminal. Exported for debugging — tree-shaken if unused. +// Order matters: boost first; then tmux can still clamp RGB to 256. +// Exported for debugging — tree-shaken if unused. export const CHALK_BOOSTED_FOR_XTERMJS = boostChalkLevelForXtermJs() export const CHALK_CLAMPED_FOR_TMUX = clampChalkLevelForTmux() +export const CHALK_USES_RICH_EIGHT_BIT_DOWNGRADE = shouldUseRichEightBitDowngradeForLegacyAppleTerminal() export type ColorType = 'foreground' | 'background' const RGB_REGEX = /^rgb\(\s?(\d+),\s?(\d+),\s?(\d+)\s?\)$/ const ANSI_REGEX = /^ansi256\(\s?(\d+)\s?\)$/ +const HEX_REGEX = /^#[0-9a-fA-F]{6}$/ export const colorize = (str: string, color: string | undefined, type: ColorType): string => { if (!color) { @@ -128,6 +163,16 @@ export const colorize = (str: string, color: string | undefined, type: ColorType } if (color.startsWith('#')) { + if (HEX_REGEX.test(color) && CHALK_USES_RICH_EIGHT_BIT_DOWNGRADE) { + const value = Number.parseInt(color.slice(1), 16) + const red = (value >> 16) & 0xff + const green = (value >> 8) & 0xff + const blue = value & 0xff + const ansi = richEightBitColorNumber(red, green, blue) + + return type === 'foreground' ? chalk.ansi256(ansi)(str) : chalk.bgAnsi256(ansi)(str) + } + return type === 'foreground' ? chalk.hex(color)(str) : chalk.bgHex(color)(str) } @@ -154,6 +199,12 @@ export const colorize = (str: string, color: string | undefined, type: ColorType const secondValue = Number(matches[2]) const thirdValue = Number(matches[3]) + if (CHALK_USES_RICH_EIGHT_BIT_DOWNGRADE) { + const ansi = richEightBitColorNumber(firstValue, secondValue, thirdValue) + + return type === 'foreground' ? chalk.ansi256(ansi)(str) : chalk.bgAnsi256(ansi)(str) + } + return type === 'foreground' ? chalk.rgb(firstValue, secondValue, thirdValue)(str) : chalk.bgRgb(firstValue, secondValue, thirdValue)(str) -
ui-tui/packages/hermes-ink/src/ink/components/Text.test.ts@@ -1,18 +1,38 @@ import { describe, expect, it } from 'vitest' -import { shouldUseAnsiDim } from './Text.js' +import { dimColorFallback, shouldUseAnsiDim } from './Text.js' describe('shouldUseAnsiDim', () => { it('disables ANSI dim on VTE terminals by default', () => { expect(shouldUseAnsiDim({ VTE_VERSION: '7603' } as NodeJS.ProcessEnv)).toBe(false) }) + it('disables ANSI dim on Apple Terminal by default', () => { + expect(shouldUseAnsiDim({ TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv)).toBe(false) + }) + it('keeps ANSI dim enabled elsewhere by default', () => { expect(shouldUseAnsiDim({ TERM: 'xterm-256color' } as NodeJS.ProcessEnv)).toBe(true) }) it('honors explicit env override', () => { expect(shouldUseAnsiDim({ HERMES_TUI_DIM: '1', VTE_VERSION: '7603' } as NodeJS.ProcessEnv)).toBe(true) + expect(shouldUseAnsiDim({ HERMES_TUI_DIM: '1', TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv)).toBe(true) expect(shouldUseAnsiDim({ HERMES_TUI_DIM: '0' } as NodeJS.ProcessEnv)).toBe(false) }) }) + +describe('dimColorFallback', () => { + it('renders Apple Terminal dim as muted gray by default', () => { + expect(dimColorFallback({ TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv)).toBe('#6B7280') + }) + + it('normalizes Apple Terminal names before matching', () => { + expect(dimColorFallback({ TERM_PROGRAM: ' Apple_Terminal ' } as NodeJS.ProcessEnv)).toBe('#6B7280') + }) + + it('does not apply when dim is explicitly configured', () => { + expect(dimColorFallback({ HERMES_TUI_DIM: '1', TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv)).toBeUndefined() + expect(dimColorFallback({ HERMES_TUI_DIM: '0', TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv)).toBeUndefined() + }) +}) -
ui-tui/packages/hermes-ink/src/ink/components/Text.tsx@@ -6,6 +6,7 @@ import type { Color, Styles } from '../styles.js' const ENV_ON_RE = /^(?:1|true|yes|on)$/i const ENV_OFF_RE = /^(?:0|false|no|off)$/i +const LEGACY_APPLE_DIM_COLOR: Color = '#6B7280' type BaseProps = { /** * Change text color. Accepts a raw color value (rgb, hex, ansi). @@ -76,9 +77,23 @@ export function shouldUseAnsiDim(env: NodeJS.ProcessEnv = process.env): boolean return false } + if ((env.TERM_PROGRAM ?? '').trim() === 'Apple_Terminal') { + return false + } + return !env.VTE_VERSION } +export function dimColorFallback(env: NodeJS.ProcessEnv = process.env): Color | undefined { + const override = (env.HERMES_TUI_DIM ?? '').trim() + + if (ENV_ON_RE.test(override) || ENV_OFF_RE.test(override)) { + return undefined + } + + return (env.TERM_PROGRAM ?? '').trim() === 'Apple_Terminal' ? LEGACY_APPLE_DIM_COLOR : undefined +} + const memoizedStylesForWrap: Record<NonNullable<Styles['textWrap']>, Styles> = { wrap: { flexGrow: 0, @@ -161,6 +176,7 @@ export default function Text(t0: Props) { const inverse = t4 === undefined ? false : t4 const wrap = t5 === undefined ? 'wrap' : t5 const effectiveDim = dim && shouldUseAnsiDim() + const effectiveColor = dim && !effectiveDim ? (color ?? dimColorFallback()) : color if (children === undefined || children === null) { return null @@ -168,11 +184,11 @@ export default function Text(t0: Props) { let t6 - if ($[0] !== color) { - t6 = color && { - color + if ($[0] !== effectiveColor) { + t6 = effectiveColor && { + color: effectiveColor } - $[0] = color + $[0] = effectiveColor $[1] = t6 } else { t6 = $[1] -
ui-tui/src/__tests__/forceTruecolor.test.ts@@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' -const ENV_KEYS = ['COLORTERM', 'FORCE_COLOR', 'HERMES_TUI_TRUECOLOR', 'NO_COLOR'] as const +const ENV_KEYS = ['COLORTERM', 'FORCE_COLOR', 'HERMES_TUI_TRUECOLOR', 'NO_COLOR', 'TERM', 'TERM_PROGRAM'] as const +let importId = 0 async function withCleanEnv(setup: () => void, body: () => Promise<void>) { const saved: Record<string, string | undefined> = {} @@ -25,11 +26,39 @@ async function withCleanEnv(setup: () => void, body: () => Promise<void>) { } describe('forceTruecolor', () => { - it('sets COLORTERM=truecolor and FORCE_COLOR=3 when unset', async () => { + it('does not force truecolor by default', async () => { await withCleanEnv( () => {}, async () => { - await import('../lib/forceTruecolor.js?t=' + Date.now()) + await import('../lib/forceTruecolor.js?t=default-' + importId++) + expect(process.env.COLORTERM).toBeUndefined() + expect(process.env.FORCE_COLOR).toBeUndefined() + } + ) + }) + + it('does not infer truecolor from Apple Terminal on pre-Tahoe macOS', async () => { + await withCleanEnv( + () => { + process.env.TERM_PROGRAM = 'Apple_Terminal' + process.env.TERM = 'xterm-256color' + }, + async () => { + const mod = await import('../lib/forceTruecolor.js?t=apple-' + importId++) + expect(mod.shouldForceTruecolor({ TERM_PROGRAM: 'Apple_Terminal' })).toBe(false) + expect(process.env.COLORTERM).toBeUndefined() + expect(process.env.FORCE_COLOR).toBeUndefined() + } + ) + }) + + it('sets COLORTERM=truecolor and FORCE_COLOR=3 when explicitly enabled', async () => { + await withCleanEnv( + () => { + process.env.HERMES_TUI_TRUECOLOR = '1' + }, + async () => { + await import('../lib/forceTruecolor.js?t=enabled-' + importId++) expect(process.env.COLORTERM).toBe('truecolor') expect(process.env.FORCE_COLOR).toBe('3') } @@ -40,9 +69,10 @@ describe('forceTruecolor', () => { await withCleanEnv( () => { process.env.HERMES_TUI_TRUECOLOR = '0' + process.env.TERM_PROGRAM = 'Apple_Terminal' }, async () => { - await import('../lib/forceTruecolor.js?t=optout-' + Date.now()) + await import('../lib/forceTruecolor.js?t=optout-' + importId++) expect(process.env.COLORTERM).toBeUndefined() expect(process.env.FORCE_COLOR).toBeUndefined() } @@ -53,12 +83,41 @@ describe('forceTruecolor', () => { await withCleanEnv( () => { process.env.NO_COLOR = '1' + process.env.HERMES_TUI_TRUECOLOR = '1' }, async () => { - await import('../lib/forceTruecolor.js?t=no-color-' + Date.now()) + await import('../lib/forceTruecolor.js?t=no-color-' + importId++) expect(process.env.COLORTERM).toBeUndefined() expect(process.env.FORCE_COLOR).toBeUndefined() } ) }) + + it('respects existing FORCE_COLOR unless Hermes truecolor is explicit', async () => { + await withCleanEnv( + () => { + process.env.FORCE_COLOR = '' + }, + async () => { + const mod = await import('../lib/forceTruecolor.js?t=force-color-' + importId++) + expect(mod.shouldForceTruecolor(process.env)).toBe(false) + expect(process.env.COLORTERM).toBeUndefined() + expect(process.env.FORCE_COLOR).toBe('') + } + ) + }) + + it('lets explicit Hermes truecolor override existing FORCE_COLOR', async () => { + await withCleanEnv( + () => { + process.env.FORCE_COLOR = '0' + process.env.HERMES_TUI_TRUECOLOR = '1' + }, + async () => { + await import('../lib/forceTruecolor.js?t=explicit-force-' + importId++) + expect(process.env.COLORTERM).toBe('truecolor') + expect(process.env.FORCE_COLOR).toBe('3') + } + ) + }) }) -
ui-tui/src/__tests__/theme.test.ts@@ -16,12 +16,13 @@ const RELEVANT_ENV = [ 'HERMES_TUI_THEME', 'HERMES_TUI_BACKGROUND', 'COLORFGBG', + 'COLORTERM', 'TERM_PROGRAM' ] as const -async function importThemeWithCleanEnv() { +async function importThemeWithEnv(env: Partial<Record<(typeof RELEVANT_ENV)[number], string>> = {}) { for (const key of RELEVANT_ENV) { - vi.stubEnv(key, '') + vi.stubEnv(key, env[key] ?? '') } vi.resetModules() @@ -29,6 +30,10 @@ async function importThemeWithCleanEnv() { return import('../theme.js') } +async function importThemeWithCleanEnv() { + return importThemeWithEnv() +} + afterEach(() => { vi.unstubAllEnvs() vi.resetModules() @@ -84,6 +89,12 @@ describe('detectLightMode', () => { expect(detectLightMode({})).toBe(false) }) + it('defaults Apple Terminal to light when no stronger signal is present', async () => { + const { detectLightMode } = await importThemeWithCleanEnv() + + expect(detectLightMode({ TERM_PROGRAM: 'Apple_Terminal' })).toBe(true) + }) + it('honors HERMES_TUI_LIGHT on/off', async () => { const { detectLightMode } = await importThemeWithCleanEnv() @@ -159,8 +170,8 @@ describe('detectLightMode', () => { it('treats COLORFGBG as authoritative when present so it dominates the TERM_PROGRAM allow-list', async () => { const { detectLightMode } = await importThemeWithCleanEnv() - // Inject a light-default allow-list so the precedence test is - // meaningful even though the production allow-list is empty. + // Injecting the allow-list keeps this precedence rule explicit even if + // production defaults change. const allowList = new Set(['Apple_Terminal']) // Sanity: the allow-list alone WOULD turn this terminal light. @@ -221,6 +232,40 @@ describe('fromSkin', () => { expect(fromSkin({}, {}).brand.icon).toBe(DEFAULT_THEME.brand.icon) }) + it('normalizes non-banner foregrounds on light Apple Terminal', async () => { + const { fromSkin } = await importThemeWithEnv({ TERM_PROGRAM: 'Apple_Terminal' }) + + const theme = fromSkin({ + banner_accent: '#FFBF00', + banner_border: '#CD7F32', + banner_dim: '#B8860B', + banner_text: '#FFF8DC', + banner_title: '#FFD700', + prompt: '#FFF8DC' + }, {}) + + expect(theme.color.primary).toBe('#FFD700') + expect(theme.color.accent).toBe('#FFBF00') + expect(theme.color.border).toBe('#CD7F32') + expect(theme.color.muted).toBe('ansi256(245)') + expect(theme.color.text).toBe('ansi256(136)') + expect(theme.color.prompt).toBe('ansi256(136)') + }) + + it('does not normalize light Apple Terminal when truecolor is advertised', async () => { + const { fromSkin } = await importThemeWithEnv({ COLORTERM: 'truecolor', TERM_PROGRAM: 'Apple_Terminal' }) + const theme = fromSkin({ banner_text: '#FFF8DC' }, {}) + + expect(theme.color.text).toBe('#FFF8DC') + }) + + it('normalizes Apple Terminal names before matching', async () => { + const { fromSkin } = await importThemeWithEnv({ TERM_PROGRAM: ' Apple_Terminal ' }) + const theme = fromSkin({ banner_text: '#FFF8DC' }, {}) + + expect(theme.color.text).toBe('ansi256(136)') + }) + it('passes banner logo/hero', async () => { const { fromSkin } = await importThemeWithCleanEnv() -
ui-tui/src/entry.tsx@@ -1,8 +1,6 @@ #!/usr/bin/env -S node --max-old-space-size=8192 --expose-gc -// Must be first import — mutates process.env.FORCE_COLOR / COLORTERM before -// any chalk / supports-color import so the banner gradient renders in -// truecolor instead of being downsampled to 256-color (which collapses -// gold #FFD700 and amber #FFBF00 to the same slot). +// Must be first import. If the user explicitly opts into truecolor, this +// nudges chalk / supports-color before either package is initialized. import './lib/forceTruecolor.js' import type { FrameEvent } from '@hermes/ink' -
ui-tui/src/lib/forceTruecolor.ts@@ -1,27 +1,25 @@ /** - * Force 24-bit truecolor output before any chalk / supports-color import. + * Targeted 24-bit truecolor override before chalk / supports-color imports. * - * Why this exists: - * The base CLI (Python/Rich) emits banner colors as truecolor ANSI - * (`\033[38;2;R;G;Bm`). The TUI renders through Ink → chalk, whose - * supports-color auto-detection defaults to 256-color on macOS Terminal.app - * and any terminal that does NOT set `COLORTERM=truecolor`. In 256-color - * mode, chalk downsamples `#FFD700` (gold) and `#FFBF00` (amber) to the - * *same* xterm-256 palette slot (220) — collapsing the banner gradient - * into a single flat yellow band. The bronze and dim rows also lose - * contrast against each other. - * - * Terminal.app (macOS 12+), iTerm2, kitty, Alacritty, VS Code, Cursor, - * and WezTerm all render truecolor correctly. The few that don't - * (ancient xterm, some CI environments) can set `HERMES_TUI_TRUECOLOR=0` - * to opt out. - * - * This MUST run before any `chalk` or `supports-color` import. supports-color - * caches its level on first load, so nudging env vars after that point has - * no effect. + * macOS Terminal.app before Tahoe 26 does not support RGB SGR, so do not + * infer truecolor from TERM_PROGRAM=Apple_Terminal. Users can still opt in + * explicitly on terminals that support RGB but do not advertise COLORTERM. */ -if (process.env.HERMES_TUI_TRUECOLOR !== '0' && !process.env.NO_COLOR && !process.env.FORCE_COLOR) { +const TRUE_RE = /^(?:1|true|yes|on)$/i +const FALSE_RE = /^(?:0|false|no|off)$/i + +export function shouldForceTruecolor(env: NodeJS.ProcessEnv = process.env): boolean { + const override = (env.HERMES_TUI_TRUECOLOR ?? '').trim() + + if (FALSE_RE.test(override) || 'NO_COLOR' in env) { + return false + } + + return TRUE_RE.test(override) +} + +if (shouldForceTruecolor()) { if (!process.env.COLORTERM) { process.env.COLORTERM = 'truecolor' } -
ui-tui/src/theme.ts@@ -76,6 +76,162 @@ function mix(a: string, b: string, t: number) { return '#' + ((1 << 24) | (lerp(0) << 16) | (lerp(1) << 8) | lerp(2)).toString(16).slice(1) } +const XTERM_6_LEVELS = [0, 95, 135, 175, 215, 255] as const +const ANSI_LIGHT_MAX_LUMINANCE = 0.72 +const ANSI_LIGHT_TARGET_LUMINANCE = 0.34 +const ANSI_LIGHT_MIN_SATURATION = 0.22 +const ANSI_MUTED_BUCKET = 245 + +const ANSI_NORMALIZED_FOREGROUNDS: readonly (keyof ThemeColors)[] = [ + 'text', + 'label', + 'ok', + 'error', + 'warn', + 'prompt', + 'statusFg', + 'statusGood', + 'statusWarn', + 'statusBad', + 'statusCritical', + 'shellDollar' +] + +const ANSI_MUTED_FOREGROUNDS: readonly (keyof ThemeColors)[] = ['muted', 'sessionLabel', 'sessionBorder'] + +function xtermEightBitRgb(colorNumber: number): [number, number, number] { + if (colorNumber >= 232) { + const value = 8 + (colorNumber - 232) * 10 + + return [value, value, value] + } + + if (colorNumber >= 16) { + const offset = colorNumber - 16 + + return [ + XTERM_6_LEVELS[Math.floor(offset / 36) % 6]!, + XTERM_6_LEVELS[Math.floor(offset / 6) % 6]!, + XTERM_6_LEVELS[offset % 6]! + ] + } + + return [0, 0, 0] +} + +function channelLuminance(value: number): number { + const normalized = value / 255 + + return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4 +} + +function relativeLuminance(red: number, green: number, blue: number): number { + return 0.2126 * channelLuminance(red) + 0.7152 * channelLuminance(green) + 0.0722 * channelLuminance(blue) +} + +function rgbToHsl(red: number, green: number, blue: number): [number, number, number] { + const rn = red / 255 + const gn = green / 255 + const bn = blue / 255 + const max = Math.max(rn, gn, bn) + const min = Math.min(rn, gn, bn) + const lightness = (max + min) / 2 + + if (max === min) { + return [0, 0, lightness] + } + + const delta = max - min + const saturation = lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min) + + const hue = + max === rn + ? (gn - bn) / delta + (gn < bn ? 6 : 0) + : max === gn + ? (bn - rn) / delta + 2 + : (rn - gn) / delta + 4 + + return [hue / 6, saturation, lightness] +} + +function circularDistance(a: number, b: number): number { + const distance = Math.abs(a - b) + + return Math.min(distance, 1 - distance) +} + +// 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 { + const [, saturation, lightness] = rgbToHsl(red, green, blue) + + if (saturation < 0.15) { + const gray = Math.round(lightness * 25) + + return gray === 0 ? 16 : gray === 25 ? 231 : 231 + gray + } + + const sixRed = red < 95 ? red / 95 : 1 + (red - 95) / 40 + const sixGreen = green < 95 ? green / 95 : 1 + (green - 95) / 40 + const sixBlue = blue < 95 ? blue / 95 : 1 + (blue - 95) / 40 + + return 16 + 36 * Math.round(sixRed) + 6 * Math.round(sixGreen) + Math.round(sixBlue) +} + +function bestReadableAnsiColor(red: number, green: number, blue: number): number { + const [hue, saturation, lightness] = rgbToHsl(red, green, blue) + let bestColor = richEightBitColorNumber(red, green, blue) + let bestScore = Number.POSITIVE_INFINITY + + for (let colorNumber = 16; colorNumber <= 255; colorNumber += 1) { + const [candidateRed, candidateGreen, candidateBlue] = xtermEightBitRgb(colorNumber) + const candidateLuminance = relativeLuminance(candidateRed, candidateGreen, candidateBlue) + + if (candidateLuminance > ANSI_LIGHT_MAX_LUMINANCE) { + continue + } + + const [candidateHue, candidateSaturation, candidateLightness] = rgbToHsl( + candidateRed, + candidateGreen, + candidateBlue + ) + + const saturationFloorPenalty = + candidateSaturation < ANSI_LIGHT_MIN_SATURATION ? (ANSI_LIGHT_MIN_SATURATION - candidateSaturation) * 3 : 0 + + const score = + circularDistance(candidateHue, hue) * 4 + + Math.abs(candidateSaturation - Math.max(ANSI_LIGHT_MIN_SATURATION, saturation)) * 0.8 + + Math.abs(candidateLightness - Math.min(lightness, ANSI_LIGHT_TARGET_LUMINANCE)) * 2 + + saturationFloorPenalty + + if (score < bestScore) { + bestColor = colorNumber + bestScore = score + } + } + + return bestColor +} + +function normalizeAnsiForeground(color: string): string { + const rgb = parseHex(color) + + if (!rgb) { + return color + } + + const richAnsi = richEightBitColorNumber(rgb[0], rgb[1], rgb[2]) + const richRgb = xtermEightBitRgb(richAnsi) + + const ansi = relativeLuminance(richRgb[0], richRgb[1], richRgb[2]) > ANSI_LIGHT_MAX_LUMINANCE + ? bestReadableAnsiColor(rgb[0], rgb[1], rgb[2]) + : richAnsi + + return `ansi256(${ansi})` +} + // ── Defaults ───────────────────────────────────────────────────────── const BRAND: ThemeBrand = { @@ -190,12 +346,11 @@ export const LIGHT_THEME: Theme = { const TRUE_RE = /^(?:1|true|yes|on)$/ const FALSE_RE = /^(?:0|false|no|off)$/ -// Reserved for future TERM_PROGRAM-based heuristics. Empty by default: -// most modern terminals (Ghostty, Warp, iTerm2, Apple_Terminal) ship a -// dark profile out of the box, so guessing wrong here is more annoying -// than missing a light user — light users can always set -// `HERMES_TUI_LIGHT=1` or `HERMES_TUI_THEME=light`. -const LIGHT_DEFAULT_TERM_PROGRAMS = new Set<string>() +// TERM_PROGRAM fallback allow-list for terminals whose default profile is +// light and which may not expose COLORFGBG. This currently includes Apple +// Terminal. Explicit HERMES_TUI_THEME / COLORFGBG signals above still win, +// so dark Apple Terminal profiles that advertise a dark background stay dark. +const LIGHT_DEFAULT_TERM_PROGRAMS = new Set<string>(['Apple_Terminal']) // Best-effort RGB → luminance check. Currently only accepts a 3- or // 6-digit hex value (with or without a leading `#`); the env var name @@ -247,7 +402,7 @@ function backgroundLuminance(raw: string): null | number { // slot 7 or 15 on light profiles; 0–15 ranges are otherwise // treated as authoritatively dark so the TERM_PROGRAM // allow-list below cannot override an explicit dark profile. -// 5. `TERM_PROGRAM` light-default allow-list (currently empty). +// 5. `TERM_PROGRAM` light-default allow-list. // // Anything we can't decide stays dark — the default Hermes palette // is the dark one. @@ -313,7 +468,42 @@ export function detectLightMode( return lightDefaultTermPrograms.has(termProgram) } -export const DEFAULT_THEME: Theme = detectLightMode() ? LIGHT_THEME : DARK_THEME +function shouldNormalizeAnsiLightTheme(env: NodeJS.ProcessEnv = process.env, isLight = detectLightMode(env)): boolean { + const colorTerm = (env.COLORTERM ?? '').trim().toLowerCase() + const termProgram = (env.TERM_PROGRAM ?? '').trim() + + return termProgram === 'Apple_Terminal' && colorTerm !== 'truecolor' && colorTerm !== '24bit' && isLight +} + +export function normalizeThemeForAnsiLightTerminal( + theme: Theme, + env: NodeJS.ProcessEnv = process.env, + isLight = detectLightMode(env) +): Theme { + if (!shouldNormalizeAnsiLightTheme(env, isLight)) { + return theme + } + + const color = { ...theme.color } + + for (const key of ANSI_NORMALIZED_FOREGROUNDS) { + color[key] = normalizeAnsiForeground(color[key]) + } + + for (const key of ANSI_MUTED_FOREGROUNDS) { + color[key] = `ansi256(${ANSI_MUTED_BUCKET})` + } + + return { ...theme, color } +} + +const DEFAULT_LIGHT_MODE = detectLightMode() + +export const DEFAULT_THEME: Theme = normalizeThemeForAnsiLightTerminal( + DEFAULT_LIGHT_MODE ? LIGHT_THEME : DARK_THEME, + process.env, + DEFAULT_LIGHT_MODE +) // ── Skin → Theme ───────────────────────────────────────────────────── @@ -333,7 +523,7 @@ export function fromSkin( const muted = c('banner_dim') ?? d.color.muted const completionBg = c('completion_menu_bg') ?? d.color.completionBg - return { + return normalizeThemeForAnsiLightTerminal({ color: { primary: c('ui_primary') ?? c('banner_title') ?? d.color.primary, accent, @@ -379,5 +569,5 @@ export function fromSkin( bannerLogo, bannerHero - } + }, process.env, DEFAULT_LIGHT_MODE) }
-
-
#26137 fix(cli): kill resize scrollback duplication + light-mode visibility
files:
cli.py,hermes_cli/skin_engine.py,tests/cli/test_cli_light_mode.pyShow the diff
-
cli.py@@ -1242,7 +1242,13 @@ def _prune_orphaned_branches(repo_root: str) -> None: def _hex_to_ansi(hex_color: str, *, bold: bool = False) -> str: - """Convert a hex color like '#268bd2' to a true-color ANSI escape.""" + """Convert a hex color like '#268bd2' to a true-color ANSI escape. + + Auto-remaps known dark-mode-tuned colors to readable light-mode + equivalents when running on a light terminal (see + _maybe_remap_for_light_mode + _LIGHT_MODE_REMAP). + """ + hex_color = _maybe_remap_for_light_mode(hex_color) try: r = int(hex_color[1:3], 16) g = int(hex_color[3:5], 16) @@ -1253,6 +1259,250 @@ def _hex_to_ansi(hex_color: str, *, bold: bool = False) -> str: return _ACCENT_ANSI_DEFAULT if bold else "\033[38;2;184;134;11m" +# ──────────────────────────────────────────────────────────────────────── +# Light/dark terminal mode detection. +# +# Mirrors ui-tui/src/theme.ts detectLightMode(). Used to decide whether +# to remap "near-white" skin colors (e.g. #FFF8DC banner_text, #B8860B +# banner_dim) to darker equivalents that are readable on a light +# Terminal.app / iTerm2 background. +# +# Detection priority: +# 1. HERMES_LIGHT / HERMES_TUI_LIGHT env (true/false) — explicit override +# 2. HERMES_TUI_THEME=light|dark — explicit theme +# 3. HERMES_TUI_BACKGROUND=#RRGGBB — explicit bg hint +# 4. COLORFGBG env (set by xterm/Konsole/urxvt) — bg slot 7/15 = light +# 5. OSC 11 query (\x1b]11;?\x1b\\) — ask the terminal directly +# 6. Default: assume dark (matches the legacy Hermes assumption) +# +# Cached after first call so we don't query the terminal repeatedly. +_LIGHT_MODE_CACHE: bool | None = None +_TRUE_RE = re.compile(r"^(1|true|on|yes|y)$") +_FALSE_RE = re.compile(r"^(0|false|off|no|n)$") +_LIGHT_DEFAULT_TERM_PROGRAMS = frozenset() # Apple_Terminal doesn't reliably indicate; require explicit + + +def _luminance_from_hex(hex_str: str) -> float | None: + s = (hex_str or "").strip().lstrip("#") + if len(s) == 3: + s = "".join(c * 2 for c in s) + if len(s) != 6 or not all(c in "0123456789abcdefABCDEF" for c in s): + return None + try: + r, g, b = int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16) + except ValueError: + return None + # Rec.709 luma + return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255.0 + + +def _query_osc11_background() -> str | None: + """Ask the terminal for its background color via OSC 11. + + Most modern terminals reply with \x1b]11;rgb:RRRR/GGGG/BBBB\x1b\\ + within a few ms. We wait up to 100ms total before giving up. + Returns "#RRGGBB" or None on timeout / non-tty. + """ + if not sys.stdin.isatty() or not sys.stdout.isatty(): + return None + try: + import termios + import tty + fd = sys.stdin.fileno() + old = termios.tcgetattr(fd) + except Exception: + return None + try: + try: + tty.setcbreak(fd) + except Exception: + return None + try: + sys.stdout.write("\x1b]11;?\x1b\\") + sys.stdout.flush() + except Exception: + return None + # Read up to ~50ms for the response + import select + deadline = time.monotonic() + 0.1 + buf = b"" + while time.monotonic() < deadline: + r, _, _ = select.select([fd], [], [], deadline - time.monotonic()) + if not r: + continue + try: + chunk = os.read(fd, 64) + except OSError: + break + if not chunk: + break + buf += chunk + if b"\x1b\\" in buf or b"\x07" in buf: + break + # Parse: \x1b]11;rgb:RRRR/GGGG/BBBB\x1b\\ + m = re.search(rb"rgb:([0-9a-fA-F]+)/([0-9a-fA-F]+)/([0-9a-fA-F]+)", buf) + if not m: + return None + # Each component is 1-4 hex digits — normalize to 8-bit + def norm(h: bytes) -> int: + v = int(h, 16) + # Scale to 0-255 based on hex length + bits = len(h) * 4 + return (v * 255) // ((1 << bits) - 1) if bits else 0 + r, g, b = norm(m.group(1)), norm(m.group(2)), norm(m.group(3)) + return f"#{r:02X}{g:02X}{b:02X}" + finally: + try: + termios.tcsetattr(fd, termios.TCSANOW, old) + except Exception: + pass + + +def _detect_light_mode() -> bool: + global _LIGHT_MODE_CACHE + if _LIGHT_MODE_CACHE is not None: + return _LIGHT_MODE_CACHE + result = False + try: + # 1. Explicit env override + for var in ("HERMES_LIGHT", "HERMES_TUI_LIGHT"): + v = (os.environ.get(var) or "").strip().lower() + if _TRUE_RE.match(v): + result = True + _LIGHT_MODE_CACHE = result + return result + if _FALSE_RE.match(v): + _LIGHT_MODE_CACHE = result + return result + # 2. Theme hint + theme = (os.environ.get("HERMES_TUI_THEME") or "").strip().lower() + if theme == "light": + result = True + _LIGHT_MODE_CACHE = result + return result + if theme == "dark": + _LIGHT_MODE_CACHE = result + return result + # 3. Explicit bg hex + bg_hint = os.environ.get("HERMES_TUI_BACKGROUND") or "" + bg_lum = _luminance_from_hex(bg_hint) + if bg_lum is not None: + result = bg_lum >= 0.5 + _LIGHT_MODE_CACHE = result + return result + # 4. COLORFGBG (xterm/Konsole/urxvt) + cfgbg = (os.environ.get("COLORFGBG") or "").strip() + if cfgbg: + last = cfgbg.split(";")[-1] if ";" in cfgbg else cfgbg + if last.isdigit(): + bg = int(last) + if bg in (7, 15): + result = True + _LIGHT_MODE_CACHE = result + return result + if 0 <= bg < 16: + _LIGHT_MODE_CACHE = result + return result + # 5. OSC 11 query (best-effort, only when stdin/stdout are TTY) + bg_color = _query_osc11_background() + if bg_color: + lum = _luminance_from_hex(bg_color) + if lum is not None: + result = lum >= 0.5 + _LIGHT_MODE_CACHE = result + return result + # 6. TERM_PROGRAM allow-list (currently empty) + tp = (os.environ.get("TERM_PROGRAM") or "").strip() + if tp in _LIGHT_DEFAULT_TERM_PROGRAMS: + result = True + except Exception: + result = False + _LIGHT_MODE_CACHE = result + return result + + +# Light-mode equivalents of skin colors that are unreadable on cream +# Terminal.app backgrounds. Used by _SkinAwareAnsi to remap colors +# at resolution time when light mode is detected. +# +# IMPORTANT: only remap colors that are used as STANDALONE foregrounds +# on the terminal's background. Don't remap colors that are paired +# with a dark bg (e.g. status bar text on bg:#1a1a2e) — those would +# become invisible the OTHER direction (dark gray on dark navy). +_LIGHT_MODE_REMAP: dict[str, str] = { + # Original (dark-mode) -> Light-mode replacement (darker, readable) + "#FFF8DC": "#1A1A1A", # cornsilk -> near-black + "#FFD700": "#9A6B00", # gold -> dark goldenrod (readable on cream) + "#FFBF00": "#8A5A00", # amber -> dark amber + "#B8860B": "#5C4500", # dark goldenrod -> deeper brown (more contrast) + "#DAA520": "#6B4F00", # goldenrod -> dark olive + "#F1E6CF": "#1A1A1A", # cream -> near-black + "#c9d1d9": "#24292F", # github-light fg + "#EAF7FF": "#0F1B26", # ice + "#F5F5F5": "#1A1A1A", + "#FFF0D4": "#1A1A1A", + "#CD7F32": "#8A4F1A", # bronze -> darker bronze + "#FFEFB5": "#3A2A00", + # NOTE: skipping #C0C0C0/#888888/#555555/#8B8682 — those are + # status-bar foregrounds paired with dark navy bg, where dark + # remap values would become invisible. +} + + +def _maybe_remap_for_light_mode(hex_color: str) -> str: + """If we're in light mode, remap a dark-mode-tuned color to a + higher-contrast equivalent. No-op in dark mode.""" + if not _detect_light_mode(): + return hex_color + if not hex_color or not hex_color.startswith("#"): + return hex_color + # Case-insensitive lookup + upper = hex_color.upper() + if upper in _LIGHT_MODE_REMAP_UPPER: + return _LIGHT_MODE_REMAP_UPPER[upper] + return hex_color + + +# Pre-uppercased lookup table for case-insensitive remapping +_LIGHT_MODE_REMAP_UPPER = {k.upper(): v for k, v in _LIGHT_MODE_REMAP.items()} + + +def _install_skin_light_mode_hook() -> None: + """Wrap SkinConfig.get_color at import time so EVERY skin color read goes + through the light-mode remap. Idempotent.""" + try: + from hermes_cli.skin_engine import SkinConfig # type: ignore[import] + except Exception: + return + if getattr(SkinConfig, "_hermes_light_mode_hook_installed", False): + return + _orig_get_color = SkinConfig.get_color + + def _wrapped_get_color(self, key, fallback=""): + value = _orig_get_color(self, key, fallback) + try: + return _maybe_remap_for_light_mode(value) + except Exception: + return value + + SkinConfig.get_color = _wrapped_get_color # type: ignore[method-assign] + SkinConfig._hermes_light_mode_hook_installed = True # type: ignore[attr-defined] + + +_install_skin_light_mode_hook() + + +# Prime the light-mode detection cache early (at module load) when +# we're running interactively so OSC 11 happens before pt grabs the +# tty. Skip for non-tty contexts (subagents, gateway, tests). +try: + if sys.stdin.isatty() and sys.stdout.isatty(): + _detect_light_mode() +except Exception: + pass + + + class _SkinAwareAnsi: """Lazy ANSI escape that resolves from the skin engine on first use. @@ -1290,7 +1540,12 @@ def reset(self) -> None: _ACCENT = _SkinAwareAnsi("response_border", "#FFD700", bold=True) -_DIM = _SkinAwareAnsi("banner_dim", "#B8860B") +# Use ANSI dim+italic attributes (\x1b[2;3m) instead of a hardcoded +# hex color so dim/thinking text inherits the terminal's default +# foreground color and stays readable in both light and dark +# Terminal.app modes. Hardcoded skin colors like #B8860B +# (dark goldenrod) become invisible against light cream backgrounds. +_DIM = "\x1b[2;3m" def _accent_hex() -> str: @@ -7947,8 +8202,8 @@ def _bg_thinking(text: str) -> None: from hermes_cli.skin_engine import get_active_skin _skin = get_active_skin() label = _skin.get_branding("response_label", "⚕ Hermes") - _resp_color = _skin.get_color("response_border", "#CD7F32") - _resp_text = _skin.get_color("banner_text", "#FFF8DC") + _resp_color = _maybe_remap_for_light_mode(_skin.get_color("response_border", "#CD7F32")) + _resp_text = _maybe_remap_for_light_mode(_skin.get_color("banner_text", "#FFF8DC")) except Exception: label = "⚕ Hermes" _resp_color = "#CD7F32" @@ -8549,7 +8804,8 @@ def _handle_skin_command(self, cmd: str): set_active_skin(new_skin) _ACCENT.reset() # Re-resolve ANSI color for the new skin - _DIM.reset() # Re-resolve dim/secondary ANSI color for the new skin + # _DIM is now a fixed dim+italic ANSI escape (terminal-default fg) + # so it doesn't need re-resolving on skin switch. if save_config_value("display.skin", new_skin): print(f" Skin set to: {new_skin} (saved)") else: @@ -10928,12 +11184,12 @@ def run_agent(): from hermes_cli.skin_engine import get_active_skin _skin = get_active_skin() label = _skin.get_branding("response_label", "⚕ Hermes") - _resp_color = _skin.get_color("response_border", "#CD7F32") - _resp_text = _skin.get_color("banner_text", "#FFF8DC") + _resp_color = _maybe_remap_for_light_mode(_skin.get_color("response_border", "#CD7F32")) + _resp_text = _maybe_remap_for_light_mode(_skin.get_color("banner_text", "#FFF8DC")) except Exception: label = "⚕ Hermes" - _resp_color = "#CD7F32" - _resp_text = "#FFF8DC" + _resp_color = _maybe_remap_for_light_mode("#CD7F32") + _resp_text = _maybe_remap_for_light_mode("#FFF8DC") is_error_response = result and (result.get("failed") or result.get("partial")) already_streamed = self._stream_started and self._stream_box_opened and not is_error_response @@ -11172,13 +11428,48 @@ def _get_tui_prompt_text(self) -> str: return "".join(text for _, text in self._get_tui_prompt_fragments()) def _build_tui_style_dict(self) -> dict[str, str]: - """Layer the active skin's prompt_toolkit colors over the base TUI style.""" + """Layer the active skin's prompt_toolkit colors over the base TUI style. + + Also rewrites any hex-color tokens in the resulting style strings + to their light-mode equivalents (via _LIGHT_MODE_REMAP) when the + terminal is detected as light. This makes the chrome readable + on cream Terminal.app backgrounds without per-skin overrides. + """ style_dict = dict(getattr(self, "_tui_style_base", {}) or {}) try: from hermes_cli.skin_engine import get_prompt_toolkit_style_overrides style_dict.update(get_prompt_toolkit_style_overrides()) except Exception: pass + # Light-mode remap on the style strings. Each value is a pt + # style string like "bg:#1a1a2e #C0C0C0 bold" — split on space, + # rewrite any "#XXX" tokens (including "bg:#XXX") through the + # light-mode remap, rejoin. + # + # CRITICAL: skip the remap entirely when a style string already + # specifies its own bg (e.g. status-bar / completion-menu styles + # with `bg:#1a1a2e ...`). Those colors were tuned for that + # specific dark bg and remapping the FG to a dark equivalent + # would produce dark-on-dark (invisible). The terminal's BG + # mode is irrelevant — what matters is the bg the style itself + # paints. + try: + if _detect_light_mode(): + def _remap_value(v: str) -> str: + if not v: + return v + tokens = [redacted:secret-assignment] + has_explicit_bg = any(t.startswith("bg:") for t in tokens) + if has_explicit_bg: + # The style paints its own bg — leave its fg alone. + return v + return " ".join( + _maybe_remap_for_light_mode(t) if t.startswith("#") else t + for t in tokens + ) + style_dict = {k: _remap_value(v or "") for k, v in style_dict.items()} + except Exception: + pass return style_dict def _apply_tui_skin_style(self) -> bool: @@ -11264,6 +11555,13 @@ def _build_tui_layout_children( def run(self): """Run the interactive CLI loop with persistent input at bottom.""" + # Detect light/dark terminal mode now (before pt grabs the tty). + # Caches the result so subsequent _hex_to_ansi / style calls + # don't risk re-querying mid-render. + try: + _detect_light_mode() + except Exception: + pass # Push the entire TUI to the bottom of the terminal so the banner, # responses, and prompt all appear pinned to the bottom — empty # space stays above, not below. This prints enough blank lines to @@ -13027,11 +13325,16 @@ def _get_voice_status(): # Style for the application self._tui_style_base = { - 'input-area': '#FFF8DC', - 'placeholder': '#555555 italic', - 'prompt': '#FFF8DC', + # Input area / prompt: empty style strings inherit the + # terminal's default foreground/background, so the typed + # text is readable in both light and dark Terminal.app + # color schemes. (Hardcoding a near-white #FFF8DC made + # input invisible on light backgrounds.) + 'input-area': '', + 'placeholder': '#888888 italic', + 'prompt': '', 'prompt-working': '#888888 italic', - 'hint': '#555555 italic', + 'hint': '#888888 italic', 'status-bar': 'bg:#1a1a2e #C0C0C0', 'status-bar-strong': 'bg:#1a1a2e #FFD700 bold', 'status-bar-dim': 'bg:#1a1a2e #8B8682', @@ -13090,19 +13393,70 @@ def _get_voice_status(): self._app = app # Store reference for clarify_callback # ── Fix ghost status-bar lines on terminal resize ────────────── - # When the terminal shrinks (e.g. un-maximize), the emulator reflows - # the previously-rendered full-width rows (status bar, input rules) - # into multiple narrower rows. prompt_toolkit's _on_resize handler - # only cursor_up()s by the stored layout height, missing the extra - # rows created by reflow — leaving ghost duplicates visible. + # Resize handling: monkey-patch prompt_toolkit's _output_screen_diff + # to suppress the deliberate "reserve vertical space" scroll-up. # - # It's not just column-shrink: widening, row-shrinking, and - # multiplexer-driven SIGWINCH-less redraws (cmux / tmux tab switch) - # all produce the same class of drift, where the renderer's tracked - # _cursor_pos.y no longer matches terminal reality. The only reliable - # recovery is a full screen-clear (\x1b[2J\x1b[H) before the next - # redraw, so we force one on every resize rather than trying to - # compute the exact drift. + # Background: prompt_toolkit's renderer (renderer.py L232-242) + # explicitly moves the cursor to the bottom of the canvas after + # painting "to make sure the terminal scrolls up, even when the + # lower lines of the canvas just contain whitespace". In + # non-fullscreen mode this scrolls chrome content (status bar, + # input rules) into terminal scrollback on every render. When + # the terminal column-shrinks, the emulator reflows the previously + # rendered full-width rows into multiple narrower rows that get + # pushed up — leaving ghost duplicates AND polluting scrollback. + # Same issue as pt #29 (open since 2014), #1675, #1933. + # + # Surgical fix: wrap _output_screen_diff so that when its internal + # `if current_height > previous_screen.height` branch fires (the + # one that does the bottom-cursor-move), we make it fall through + # by inflating previous_screen.height first. + try: + import prompt_toolkit.renderer as _pt_renderer + from prompt_toolkit.renderer import _output_screen_diff as _orig_osd + + if not getattr(_pt_renderer, "_hermes_osd_patched", False): + def _patched_output_screen_diff( + app, output, screen, current_pos, color_depth, + previous_screen, last_style, is_done, full_screen, + attrs_for_style_string, style_string_has_style, + size, previous_width, + ): + """Wraps pt's _output_screen_diff to suppress the + reserve-vertical-space scroll (renderer.py L232-242). + + Strategy: ONLY when previous_screen is non-None and + its current height is genuinely smaller than the new + screen's height, inflate it to match. This prevents + the bottom-cursor-move at L242 without changing any + other code path's behavior. + + Critical: do NOT replace a None previous_screen with + a fresh Screen() — that would skip the proper + reset_attributes()+erase_down() at L178-185 which + fires when previous_screen is None (first-paint / + width-change). Without that reset, ANSI styles + leak between renders. + """ + try: + if previous_screen is not None and hasattr(previous_screen, "height"): + if previous_screen.height < screen.height: + previous_screen.height = screen.height + except Exception: + pass + + return _orig_osd( + app, output, screen, current_pos, color_depth, + previous_screen, last_style, is_done, full_screen, + attrs_for_style_string, style_string_has_style, + size, previous_width, + ) + + _pt_renderer._output_screen_diff = _patched_output_screen_diff + _pt_renderer._hermes_osd_patched = True + except Exception: + pass + _original_on_resize = app._on_resize def _resize_clear_ghosts(): -
hermes_cli/skin_engine.py@@ -849,10 +849,14 @@ def get_prompt_toolkit_style_overrides() -> Dict[str, str]: except Exception: return {} - prompt = skin.get_color("prompt", "#FFF8DC") + # Input/prompt: leave unset by default so the typed text inherits + # the terminal's foreground color (readable in both light and dark + # color schemes). Skins can opt into a colored prompt by setting + # `prompt` explicitly in their YAML. + prompt = skin.get_color("prompt", "") input_rule = skin.get_color("input_rule", "#CD7F32") title = skin.get_color("banner_title", "#FFD700") - text = skin.get_color("banner_text", prompt) + text = skin.get_color("banner_text", "#FFF8DC") dim = skin.get_color("banner_dim", "#555555") label = skin.get_color("ui_label", title) warn = skin.get_color("ui_warn", "#FF8C00") @@ -872,7 +876,11 @@ def get_prompt_toolkit_style_overrides() -> Dict[str, str]: menu_meta_current_bg = skin.get_color("completion_menu_meta_current_bg", menu_current_bg) return { - "input-area": prompt, + # Typed input always uses terminal default fg/bg so it's + # readable in both light and dark Terminal.app modes. The + # skin's `prompt` color (if any) only styles the prompt symbol, + # NOT the user's typed text. + "input-area": "", "placeholder": f"{dim} italic", "prompt": prompt, "prompt-working": f"{dim} italic", -
tests/cli/test_cli_light_mode.py@@ -0,0 +1,154 @@ +"""Tests for the light-mode terminal detection + color remap in cli.py. + +Covers the env-override path and the SkinConfig.get_color() wrapper that +the resize / light-mode salvage installs at module import time. We don't +try to fake an OSC 11 reply — the env-override branch short-circuits +before the terminal query, which is the path most users hit. +""" + +from __future__ import annotations + +import importlib + +import pytest + + [redacted:email] +def cli_mod(monkeypatch): + """Import cli with the light-mode cache cleared each test.""" + import cli as _cli + + # The module-level _install_skin_light_mode_hook() and import-time + # _detect_light_mode() prime ran once at first import. We just reset + # the detection cache so the per-test env override takes effect. + monkeypatch.setattr(_cli, "_LIGHT_MODE_CACHE", None) + return _cli + + +class TestLightModeDetection: + def test_hermes_light_env_true_forces_light(self, cli_mod, monkeypatch): + monkeypatch.setenv("HERMES_LIGHT", "1") + assert cli_mod._detect_light_mode() is True + + def test_hermes_light_env_false_forces_dark(self, cli_mod, monkeypatch): + monkeypatch.setenv("HERMES_LIGHT", "0") + # Also blank out other signals so nothing else flips it light. + monkeypatch.delenv("HERMES_TUI_LIGHT", raising=False) + monkeypatch.delenv("HERMES_TUI_THEME", raising=False) + monkeypatch.delenv("HERMES_TUI_BACKGROUND", raising=False) + monkeypatch.delenv("COLORFGBG", raising=False) + assert cli_mod._detect_light_mode() is False + + def test_theme_hint_light(self, cli_mod, monkeypatch): + monkeypatch.delenv("HERMES_LIGHT", raising=False) + monkeypatch.delenv("HERMES_TUI_LIGHT", raising=False) + monkeypatch.setenv("HERMES_TUI_THEME", "light") + assert cli_mod._detect_light_mode() is True + + def test_background_hex_hint_light(self, cli_mod, monkeypatch): + monkeypatch.delenv("HERMES_LIGHT", raising=False) + monkeypatch.delenv("HERMES_TUI_LIGHT", raising=False) + monkeypatch.delenv("HERMES_TUI_THEME", raising=False) + monkeypatch.setenv("HERMES_TUI_BACKGROUND", "#FFFFFF") + assert cli_mod._detect_light_mode() is True + + def test_background_hex_hint_dark(self, cli_mod, monkeypatch): + monkeypatch.delenv("HERMES_LIGHT", raising=False) + monkeypatch.delenv("HERMES_TUI_LIGHT", raising=False) + monkeypatch.delenv("HERMES_TUI_THEME", raising=False) + monkeypatch.setenv("HERMES_TUI_BACKGROUND", "#1a1a2e") + monkeypatch.delenv("COLORFGBG", raising=False) + assert cli_mod._detect_light_mode() is False + + def test_colorfgbg_light_bg_slot(self, cli_mod, monkeypatch): + monkeypatch.delenv("HERMES_LIGHT", raising=False) + monkeypatch.delenv("HERMES_TUI_LIGHT", raising=False) + monkeypatch.delenv("HERMES_TUI_THEME", raising=False) + monkeypatch.delenv("HERMES_TUI_BACKGROUND", raising=False) + monkeypatch.setenv("COLORFGBG", "0;15") # bg slot 15 = light + assert cli_mod._detect_light_mode() is True + + def test_cache_is_sticky(self, cli_mod, monkeypatch): + monkeypatch.setenv("HERMES_LIGHT", "1") + assert cli_mod._detect_light_mode() is True + # Even if the env flips, the cached result wins until reset. + monkeypatch.setenv("HERMES_LIGHT", "0") + assert cli_mod._detect_light_mode() is True + + +class TestLightModeRemap: + def test_remap_no_op_in_dark_mode(self, cli_mod, monkeypatch): + monkeypatch.setenv("HERMES_LIGHT", "0") + # Cache is None from the fixture; first call sticks at False. + assert cli_mod._maybe_remap_for_light_mode("#FFF8DC") == "#FFF8DC" + + def test_remap_known_dark_color(self, cli_mod, monkeypatch): + monkeypatch.setenv("HERMES_LIGHT", "1") + # Force the detect cache to True for this test. + cli_mod._LIGHT_MODE_CACHE = True + assert cli_mod._maybe_remap_for_light_mode("#FFF8DC") == "#1A1A1A" + assert cli_mod._maybe_remap_for_light_mode("#FFD700") == "#9A6B00" + + def test_remap_case_insensitive(self, cli_mod, monkeypatch): + cli_mod._LIGHT_MODE_CACHE = True + # Lowercase input should still remap. + assert cli_mod._maybe_remap_for_light_mode("#fff8dc") == "#1A1A1A" + + def test_remap_unknown_color_passthrough(self, cli_mod, monkeypatch): + cli_mod._LIGHT_MODE_CACHE = True + # A color not in the remap table is returned unchanged. + assert cli_mod._maybe_remap_for_light_mode("#ABCDEF") == "#ABCDEF" + + def test_remap_skips_statusbar_paired_colors(self, cli_mod, monkeypatch): + """Colors that live on a dark bg (status bar fg) MUST NOT be + remapped — otherwise they go dark-on-dark and disappear. + + Regression guard for the patch-11 fix (intentional table omission). + """ + cli_mod._LIGHT_MODE_CACHE = True + for fg in ("#C0C0C0", "#888888", "#555555", "#8B8682"): + assert cli_mod._maybe_remap_for_light_mode(fg) == fg, ( + f"{fg} is a status-bar fg paired with dark bg; remapping it " + "would produce dark-on-dark" + ) + + +class TestSkinConfigHook: + """The salvage wraps SkinConfig.get_color at module import time so + every skin color read goes through the light-mode remap. Verify + the hook installed and functions correctly. + """ + + def test_hook_installed(self, cli_mod): + from hermes_cli.skin_engine import SkinConfig + + assert getattr(SkinConfig, "_hermes_light_mode_hook_installed", False) is True + + def test_hook_is_idempotent(self, cli_mod): + # Calling the installer twice must not double-wrap (the marker + # attribute is the guard). + from hermes_cli.skin_engine import SkinConfig + + before = SkinConfig.get_color + cli_mod._install_skin_light_mode_hook() + after = SkinConfig.get_color + assert before is after + + def test_skin_color_remaps_through_wrapper_in_light_mode(self, cli_mod, monkeypatch): + from hermes_cli.skin_engine import SkinConfig + + cli_mod._LIGHT_MODE_CACHE = True + skin = SkinConfig( + name="test", + colors={"banner_text": "#FFF8DC", "response_border": "#FFD700"}, + ) + # The wrapper kicks in at get_color, not at construction time. + assert skin.get_color("banner_text") == "#1A1A1A" + assert skin.get_color("response_border") == "#9A6B00" + + def test_skin_color_passthrough_in_dark_mode(self, cli_mod, monkeypatch): + from hermes_cli.skin_engine import SkinConfig + + cli_mod._LIGHT_MODE_CACHE = False + skin = SkinConfig(name="test", colors={"banner_text": "#FFF8DC"}) + assert skin.get_color("banner_text") == "#FFF8DC"
-
13 PRs closed without merging
From the close comments: stale or abandoned 4 · done elsewhere 2 · superseded by another PR 2 · duplicate 1 · merged via another change 1 · won't fix / by design 1 · other 2
-
#4811 feat(skin): add built-in light skin for light/cream terminal backgrounds · merged via another change →
#9461 -
#8567 fix(cli): improve default skin contrast on light terminals · won't fix / by design →
#9461 -
#9808 fix(colors): readable defaults on light terminals via HERMES_THEME + COLORFGBG · superseded by another PR →
#17113,#13103,#17695,#26137 -
#11311 fix(tui): replace hardcoded yellow colors unreadable on light backgrounds · duplicate →
#11335 - #11335 fix(tui): replace bright yellow with dark_goldenrod for light bg contrast · stale or abandoned
Show 8 more
-
#11536 fix(cli): auto-select daylight skin on light terminal backgrounds · other →
#11553,#12337,#11300,#4807 -
#11553 fix: add light terminal background detection · done elsewhere →
#11300 -
#12337 fix: adaptive color scheme for light terminal backgrounds (#12330) · superseded by another PR →
#17113,#26137 - #12487 fix(tui): replace unreadable yellow colors with dark goldenrod (#11300) · other
-
#60171 fix(tui): improve light theme text contrast · done elsewhere →
4cc6da8,#17695,#60141 - #60274 fix(desktop): use default font-smoothing on Retina to fix faint text (#59751) · stale or abandoned
- #60452 fix(desktop): use default font-smoothing on Retina to fix faint text (#59751) · stale or abandoned
- #61553 fix(cli,tui): skip unreliable COLORFGBG in Zed terminal (#61536) · stale or abandoned
Closed issues · 6
- Closed#4807[Bug]: CLI is unreadable on light/cream terminal backgrounds — no light mode support
- Closed#8526[Bug]: [color] default color is broken, when launching hermes via terminal, not being readable
- Closed#11300[Bug]: TUI hardcodes bright yellow colors, unreadable on white terminal backgrounds
- Closed#12330[Bug]: The text of Hermes is hard to read on a white terminal background.
- Closed#19039[Bug]: CLI teminal interface hard to read in day/light background.
- Closed#60141[Bug]: Hermes TUI assistant reply text remains low-contrast on macOS Terminal.app light profile
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.06: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab