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

5open issues
3open PRs
32items grouped
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.

These 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

  • PR#58545fix(qr): use invert=False for QR code terminal rendering (preserve quiet zone)
  • PR#60972fix(cli): improve light terminal body contrast
  • PR#61600fix(theme): ignore inherited COLORFGBG in Zed

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #9461 feat(skin): add light-mode skins + skin-aware completion menus

    merged to main

    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.md

    Show 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)

    merged to main

    files: ui-tui/src/__tests__/theme.test.ts, ui-tui/src/components/branding.tsx, ui-tui/src/theme.ts

    Show 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)

    merged to main

    files: ui-tui/src/__tests__/theme.test.ts, ui-tui/src/theme.ts

    Show 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

    merged to main

    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.ts

    Show 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

    merged to main

    files: cli.py, hermes_cli/skin_engine.py, tests/cli/test_cli_light_mode.py

    Show 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.00 means 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

At a glance

Subsystem
apps/desktop
Merged PRs
#9461, #13103, #17113, #17695, #26137
Closed issues
6
Attempts
13 PRs closed without merging
Fix link checked
15 May 2026

For your agent

The same facts, typed.