Merge pull request #20379 from NousResearch/bb/widget-grid-slots

feat(ui-tui): widget-grid layout engine + background-aware theme engine
This commit is contained in:
brooklyn! 2026-07-21 17:51:58 -05:00 committed by GitHub
commit e0e15436ee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
57 changed files with 5905 additions and 454 deletions

View file

@ -48,6 +48,16 @@ All fields are optional. Missing values inherit from the ``default`` skin.
completion_menu_meta_bg: "#1a1a2e" # Completion meta column background
completion_menu_meta_current_bg: "#333355" # Active completion meta background
# Optional paired palette for the opposite terminal polarity (mirrors the
# desktop app's colors/darkColors pairing). If `colors` above is authored
# for dark terminals, `light_colors` supplies the hand-tuned light-terminal
# variant (same keys); light-authored skins supply `dark_colors` instead.
# Without a paired block, the TUI adapts `colors` automatically
# (contrast-clamped foregrounds, polarity-corrected fills).
light_colors:
banner_title: "#8B6914"
# ... same keys as `colors` ...
# Spinner: customize the animated spinner during API calls
spinner:
waiting_faces: # Faces shown while waiting for API
@ -132,6 +142,14 @@ class SkinConfig:
name: str
description: str = ""
colors: Dict[str, str] = field(default_factory=dict)
# Paired palettes for terminals whose background polarity differs from the
# one `colors` was authored against (mirrors the desktop app's
# colors/darkColors pairing). A consumer that knows the terminal is light
# prefers `light_colors` (falling back to `colors`), and vice versa for
# `dark_colors`. Both merge over the default skin's matching block, so
# partial user skins still resolve to a complete palette.
light_colors: Dict[str, str] = field(default_factory=dict)
dark_colors: Dict[str, str] = field(default_factory=dict)
spinner: Dict[str, Any] = field(default_factory=dict)
branding: Dict[str, str] = field(default_factory=dict)
tool_prefix: str = ""
@ -165,6 +183,8 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
"default": {
"name": "default",
"description": "Classic Hermes — gold and kawaii",
# Dark-authored. Values match the TUI's DARK_THEME so the classic CLI
# and the TUI render the same Hermes gold.
"colors": {
"banner_border": "#CD7F32",
"banner_title": "#FFD700",
@ -180,8 +200,56 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
"input_rule": "#CD7F32",
"response_border": "#FFD700",
"status_bar_bg": "#1a1a2e",
"status_bar_text": "#C0C0C0",
"status_bar_strong": "#FFD700",
"status_bar_dim": "#8A7A4A",
"status_bar_good": "#8FBC8F",
"status_bar_warn": "#FFD700",
"status_bar_bad": "#FF8C00",
"status_bar_critical": "#FF6B6B",
"session_label": "#DAA520",
"session_border": "#8B8682",
"completion_menu_bg": "#1a1a2e",
"completion_menu_current_bg": "#333355",
"selection_bg": "#3a3a55",
"shell_dollar": "#4dabf7",
"voice_status_bg": "#1a1a2e",
},
# Light overlay (merged onto `colors`; dark mode renders the vivid
# block above untouched). The goldenrod ladder: on white, the vivid
# #FFD700/#FFBF00 read as glare and WCAG-darkened mustard (#867000)
# reads as mud — the sweet spot is the statusbar's goldenrod family
# (#B8860B/#DAA520): hue kept, saturation tamed, mid luminance.
# Hierarchy on white: ink body 8.9:1 > fade 5.2 > label 3.7 >
# muted 3.3 > title 2.7 > headers 2.4 (accents recede last, like
# slate's pastels — the raw-canon look, just not neon).
"light_colors": {
"banner_title": "#C8961E",
"banner_accent": "#D89B04",
"banner_dim": "#B8860B",
"banner_text": "#5C4718",
"ui_accent": "#D89B04",
"ui_label": "#A97E10",
"ui_ok": "#2E7D32",
"ui_error": "#C62828",
"ui_warn": "#D97706",
"prompt": "#5C4718",
"response_border": "#C8961E",
"session_label": "#A97E10",
"status_bar_text": "#6F6F6F",
"status_bar_strong": "#C8961E",
"status_bar_dim": "#9A8A5A",
"status_bar_good": "#2E7D32",
"status_bar_warn": "#C8961E",
"status_bar_bad": "#C2410C",
"status_bar_critical": "#B91C1C",
"shell_dollar": "#1E6FC0",
# Fills: flip the dark navy surfaces to light polarity.
"completion_menu_bg": "#F5F5F5",
"completion_menu_current_bg": "#E0D1BF",
"selection_bg": "#D4E4F7",
"status_bar_bg": "#F5F5F5",
"voice_status_bg": "#F5F5F5",
},
"spinner": {
# Empty = use hardcoded defaults in display.py
@ -200,10 +268,10 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
"name": "ares",
"description": "War-god theme — crimson and bronze",
"colors": {
"banner_border": "#9F1C1C",
"banner_border": "#A93333",
"banner_title": "#C7A96B",
"banner_accent": "#DD4A3A",
"banner_dim": "#6B1717",
"banner_dim": "#905151",
"banner_text": "#F1E6CF",
"ui_accent": "#DD4A3A",
"ui_label": "#C7A96B",
@ -211,18 +279,23 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
"ui_error": "#ef5350",
"ui_warn": "#ffa726",
"prompt": "#F1E6CF",
"input_rule": "#9F1C1C",
"input_rule": "#A93333",
"response_border": "#C7A96B",
"status_bar_bg": "#2A1212",
"status_bar_text": "#F1E6CF",
"status_bar_strong": "#C7A96B",
"status_bar_dim": "#6E584B",
"status_bar_dim": "#756054",
"status_bar_good": "#7BC96F",
"status_bar_warn": "#C7A96B",
"status_bar_bad": "#DD4A3A",
"status_bar_critical": "#EF5350",
"session_label": "#C7A96B",
"session_border": "#6E584B",
"completion_menu_bg": "#2A1212",
"completion_menu_current_bg": "#5C221D",
"selection_bg": "#692620",
"shell_dollar": "#DD4A3A",
"voice_status_bg": "#2A1212",
},
"spinner": {
"waiting_faces": ["(⚔)", "(⛨)", "(▲)", "(<>)", "(/)"],
@ -272,10 +345,10 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
"name": "mono",
"description": "Monochrome — clean grayscale",
"colors": {
"banner_border": "#555555",
"banner_border": "#5E5E5E",
"banner_title": "#e6edf3",
"banner_accent": "#aaaaaa",
"banner_dim": "#444444",
"banner_dim": "#606060",
"banner_text": "#c9d1d9",
"ui_accent": "#aaaaaa",
"ui_label": "#888888",
@ -283,7 +356,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
"ui_error": "#cccccc",
"ui_warn": "#999999",
"prompt": "#c9d1d9",
"input_rule": "#444444",
"input_rule": "#606060",
"response_border": "#aaaaaa",
"status_bar_bg": "#1F1F1F",
"status_bar_text": "#C9D1D9",
@ -294,7 +367,12 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
"status_bar_bad": "#D0D0D0",
"status_bar_critical": "#F0F0F0",
"session_label": "#888888",
"session_border": "#555555",
"session_border": "#5E5E5E",
"completion_menu_bg": "#1F1F1F",
"completion_menu_current_bg": "#464646",
"selection_bg": "#505050",
"shell_dollar": "#aaaaaa",
"voice_status_bg": "#1F1F1F",
},
"spinner": {},
"branding": {
@ -314,7 +392,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
"banner_border": "#4169e1",
"banner_title": "#7eb8f6",
"banner_accent": "#8EA8FF",
"banner_dim": "#4b5563",
"banner_dim": "#545E6B",
"banner_text": "#c9d1d9",
"ui_accent": "#7eb8f6",
"ui_label": "#8EA8FF",
@ -327,13 +405,18 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
"status_bar_bg": "#151C2F",
"status_bar_text": "#C9D1D9",
"status_bar_strong": "#7EB8F6",
"status_bar_dim": "#4B5563",
"status_bar_dim": "#5D6672",
"status_bar_good": "#63D0A6",
"status_bar_warn": "#E6A855",
"status_bar_bad": "#F7A072",
"status_bar_critical": "#FF7A7A",
"session_label": "#7eb8f6",
"session_border": "#4b5563",
"session_border": "#545E6B",
"completion_menu_bg": "#151C2F",
"completion_menu_current_bg": "#324867",
"selection_bg": "#3A5375",
"shell_dollar": "#7eb8f6",
"voice_status_bg": "#151C2F",
},
"spinner": {},
"branding": {
@ -361,16 +444,25 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
"ui_error": "#B91C1C",
"ui_warn": "#B45309",
"prompt": "#111827",
"input_rule": "#93C5FD",
"input_rule": "#6E94BE",
"response_border": "#2563EB",
"status_bar_bg": "#E5EDF8",
"status_bar_text": "#111827",
"status_bar_strong": "#2563EB",
"status_bar_dim": "#838890",
"status_bar_good": "#15803D",
"status_bar_warn": "#B45309",
"status_bar_bad": "#B45309",
"status_bar_critical": "#B91C1C",
"session_label": "#1D4ED8",
"session_border": "#64748B",
"status_bar_bg": "#E5EDF8",
"voice_status_bg": "#E5EDF8",
"completion_menu_bg": "#F8FAFC",
"completion_menu_current_bg": "#DBEAFE",
"completion_menu_meta_bg": "#EEF2FF",
"completion_menu_meta_current_bg": "#BFDBFE",
"selection_bg": "#D3E0FB",
"shell_dollar": "#2563EB",
"voice_status_bg": "#E5EDF8",
},
"spinner": {},
"branding": {
@ -400,14 +492,23 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
"prompt": "#2C1810",
"input_rule": "#8B6914",
"response_border": "#8B6914",
"status_bar_bg": "#F5F0E8",
"status_bar_text": "#2C1810",
"status_bar_strong": "#8B4513",
"status_bar_dim": "#8A8F98",
"status_bar_good": "#2E7D32",
"status_bar_warn": "#E65100",
"status_bar_bad": "#DA4D00",
"status_bar_critical": "#C62828",
"session_label": "#5C3D11",
"session_border": "#A0845C",
"status_bar_bg": "#F5F0E8",
"voice_status_bg": "#F5F0E8",
"completion_menu_bg": "#F5EFE0",
"completion_menu_current_bg": "#E8DCC8",
"completion_menu_meta_bg": "#F0E8D8",
"completion_menu_meta_current_bg": "#DFCFB0",
"selection_bg": "#E8DAD0",
"shell_dollar": "#8B4513",
"voice_status_bg": "#F5F0E8",
},
"spinner": {},
"branding": {
@ -427,7 +528,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
"banner_border": "#2A6FB9",
"banner_title": "#A9DFFF",
"banner_accent": "#5DB8F5",
"banner_dim": "#153C73",
"banner_dim": "#44638F",
"banner_text": "#EAF7FF",
"ui_accent": "#5DB8F5",
"ui_label": "#A9DFFF",
@ -440,13 +541,18 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
"status_bar_bg": "#0F2440",
"status_bar_text": "#EAF7FF",
"status_bar_strong": "#A9DFFF",
"status_bar_dim": "#496884",
"status_bar_dim": "#52708A",
"status_bar_good": "#6ED7B0",
"status_bar_warn": "#5DB8F5",
"status_bar_bad": "#2A6FB9",
"status_bar_bad": "#3576BC",
"status_bar_critical": "#D94F4F",
"session_label": "#A9DFFF",
"session_border": "#496884",
"completion_menu_bg": "#0F2440",
"completion_menu_current_bg": "#254D73",
"selection_bg": "#2A587F",
"shell_dollar": "#5DB8F5",
"voice_status_bg": "#0F2440",
},
"spinner": {
"waiting_faces": ["(≈)", "(Ψ)", "(∿)", "(◌)", "(◠)"],
@ -499,7 +605,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
"banner_border": "#B7B7B7",
"banner_title": "#F5F5F5",
"banner_accent": "#E7E7E7",
"banner_dim": "#4A4A4A",
"banner_dim": "#5C5C5C",
"banner_text": "#D3D3D3",
"ui_accent": "#E7E7E7",
"ui_label": "#D3D3D3",
@ -512,13 +618,18 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
"status_bar_bg": "#202020",
"status_bar_text": "#D3D3D3",
"status_bar_strong": "#F5F5F5",
"status_bar_dim": "#656565",
"status_bar_dim": "#6D6D6D",
"status_bar_good": "#B7B7B7",
"status_bar_warn": "#D3D3D3",
"status_bar_bad": "#E7E7E7",
"status_bar_critical": "#F5F5F5",
"session_label": "#919191",
"session_border": "#656565",
"completion_menu_bg": "#202020",
"completion_menu_current_bg": "#585858",
"selection_bg": "#666666",
"shell_dollar": "#E7E7E7",
"voice_status_bg": "#202020",
},
"spinner": {
"waiting_faces": ["(◉)", "(◌)", "(◬)", "(⬤)", "(::)"],
@ -585,18 +696,20 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
"status_bar_bg": "#2B160E",
"status_bar_text": "#FFF0D4",
"status_bar_strong": "#FFD39A",
"status_bar_dim": "#6C4724",
"status_bar_dim": "#826144",
"status_bar_good": "#6BCB77",
"status_bar_warn": "#F29C38",
"status_bar_bad": "#E2832B",
"status_bar_critical": "#EF5350",
"session_label": "#FFD39A",
"session_border": "#6C4724",
"selection_bg": "#5A260D",
"session_border": "#7B593A",
"completion_menu_bg": "#0B0503",
"completion_menu_current_bg": "#4A1B07",
"completion_menu_meta_bg": "#120806",
"completion_menu_meta_current_bg": "#5A260D",
"selection_bg": "#5A260D",
"shell_dollar": "#F29C38",
"voice_status_bg": "#2B160E",
},
"spinner": {
"waiting_faces": ["(✦)", "(▲)", "(◇)", "(<>)", "(🔥)"],
@ -703,10 +816,20 @@ def _build_skin_config(data: Dict[str, Any]) -> SkinConfig:
branding = dict(default.get("branding", {}))
branding.update(branding_overrides)
# Paired palettes are NOT merged over the default skin's blocks: an empty
# block means "this skin has no hand-tuned variant for that polarity", and
# consumers (the TUI) fall back to `colors` + automatic adaptation. Merging
# the default's gold light palette under a crimson skin would be worse
# than adapting the crimson.
light_colors = _mapping_or_empty(data.get("light_colors"), section="light_colors", skin_name=skin_name)
dark_colors = _mapping_or_empty(data.get("dark_colors"), section="dark_colors", skin_name=skin_name)
return SkinConfig(
name=skin_name,
description=data.get("description", ""),
colors=colors,
light_colors=light_colors,
dark_colors=dark_colors,
spinner=spinner,
branding=branding,
tool_prefix=data.get("tool_prefix", default.get("tool_prefix", "")),

View file

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

View file

@ -0,0 +1,223 @@
"""Built-in skin palette audit: completeness + WCAG contrast, per polarity.
Every built-in skin must be a complete, coherent palette with no accidental
fallbacks (a partial skin inherits the default skin's gold, which is how
"slate feels all over the place" happened), and every palette must be a
fixed point of the TUI's runtime readability adaptation — hand-tuned values
that already pass the same contrast floors the TUI enforces (strong >= 3.9,
soft >= 2.8, fills matching the background polarity). Mirrors the desktop
app's paired colors/darkColors contract.
"""
import pytest
from hermes_cli.skin_engine import _BUILTIN_SKINS
# Union of the color keys consumed by the TUI (fromSkin) and the classic CLI
# (banner.py / display.py / prompt_toolkit overrides). completion_menu_meta_*
# intentionally excluded: they default to the base menu keys.
REQUIRED_KEYS = frozenset(
{
"banner_border",
"banner_title",
"banner_accent",
"banner_dim",
"banner_text",
"ui_accent",
"ui_label",
"ui_ok",
"ui_error",
"ui_warn",
"prompt",
"input_rule",
"response_border",
"status_bar_bg",
"status_bar_text",
"status_bar_strong",
"status_bar_dim",
"status_bar_good",
"status_bar_warn",
"status_bar_bad",
"status_bar_critical",
"session_label",
"session_border",
"completion_menu_bg",
"completion_menu_current_bg",
"selection_bg",
"shell_dollar",
"voice_status_bg",
}
)
# Foreground roles and their minimum contrast against the palette's pole.
# Matches ui-tui/src/theme.ts STRONG/SOFT tiers.
STRONG_FG = (
"banner_title",
"banner_accent",
"banner_text",
"ui_accent",
"ui_label",
"ui_ok",
"ui_error",
"prompt",
"status_bar_strong",
"status_bar_good",
"status_bar_bad",
"status_bar_critical",
"shell_dollar",
)
SOFT_FG = (
"banner_dim",
"banner_border",
"ui_warn",
"input_rule",
"response_border",
"status_bar_dim",
"status_bar_warn",
"session_label",
"session_border",
)
# status_bar_text renders on status_bar_bg, not the terminal background.
ON_STATUS_BAR = ("status_bar_text", "status_bar_strong", "status_bar_dim")
FILLS = (
"status_bar_bg",
"completion_menu_bg",
"completion_menu_current_bg",
"selection_bg",
"voice_status_bg",
)
STRONG_MIN = 3.9
SOFT_MIN = 2.8
# Assumed terminal poles, matching ui-tui/src/theme.ts referenceBackground().
DARK_POLE = "#101014"
LIGHT_POLE = "#ffffff"
def _rgb(hex_color: str):
h = hex_color.lstrip("#")
assert len(h) == 6, f"not a 6-digit hex: {hex_color!r}"
return tuple(int(h[i : i + 2], 16) for i in (0, 2, 4))
def _channel(v: float) -> float:
c = v / 255
return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
def luminance(hex_color: str) -> float:
r, g, b = _rgb(hex_color)
return 0.2126 * _channel(r) + 0.7152 * _channel(g) + 0.0722 * _channel(b)
def contrast(a: str, b: str) -> float:
la, lb = luminance(a), luminance(b)
hi, lo = max(la, lb), min(la, lb)
return (hi + 0.05) / (lo + 0.05)
# Light-authored built-ins (everything else is dark-authored). Paired
# palettes are OPTIONAL, hand-authored refinements: cross-polarity rendering
# is handled by the TUI's display shim (xterm-style hue-preserving contrast
# lift), which reproduces exactly what minimumContrastRatio hosts display —
# the look the maintainers standardized on. Generated paired palettes are
# explicitly NOT wanted; only ship one when a human tuned it.
LIGHT_AUTHORED = frozenset({"daylight", "warm-lightmode"})
# A `light_colors`/`dark_colors` block is an OVERLAY on `colors`, not a full
# replacement — a skin may ship a fills-only light overlay (flip the dark navy
# menu/status fills to light) while its vivid foregrounds keep coming from
# `colors` and render raw. So only the base `colors` block is held to the
# completeness + full foreground-contrast contract; overlays are audited for
# valid keys and fill polarity only.
def _base_palettes():
"""Yield (skin, palette, is_light) for every built-in's base `colors`."""
for name, skin in _BUILTIN_SKINS.items():
yield name, skin.get("colors", {}), name in LIGHT_AUTHORED
def _overlays():
"""Yield (skin, block, palette, is_light) for every partial overlay block."""
for name, skin in _BUILTIN_SKINS.items():
if skin.get("light_colors"):
yield name, "light_colors", skin["light_colors"], True
if skin.get("dark_colors"):
yield name, "dark_colors", skin["dark_colors"], False
BASE_PALETTES = list(_base_palettes())
BASE_IDS = [f"{skin}:colors" for skin, _, _ in BASE_PALETTES]
OVERLAYS = list(_overlays())
OVERLAY_IDS = [f"{skin}:{block}" for skin, block, _, _ in OVERLAYS]
@pytest.mark.parametrize(("skin", "palette", "is_light"), BASE_PALETTES, ids=BASE_IDS)
def test_base_palette_is_complete(skin, palette, is_light):
missing = REQUIRED_KEYS - palette.keys()
assert not missing, f"{skin}.colors missing keys: {sorted(missing)}"
@pytest.mark.parametrize(("skin", "palette", "is_light"), BASE_PALETTES, ids=BASE_IDS)
def test_base_palette_contrast_and_polarity(skin, palette, is_light):
pole = LIGHT_POLE if is_light else DARK_POLE
problems = []
# Light-authored bases render on a light pole where the classic look is the
# vivid palette rendered RAW (transparent terminals apply no lift), so the
# firm STRONG/SOFT floors only apply to dark-authored bases on a dark pole.
if not is_light:
for key in STRONG_FG:
ratio = contrast(palette[key], pole)
if ratio < STRONG_MIN:
problems.append(f"{key}={palette[key]} contrast {ratio:.2f} < {STRONG_MIN} vs {pole}")
for key in SOFT_FG:
ratio = contrast(palette[key], pole)
if ratio < SOFT_MIN:
problems.append(f"{key}={palette[key]} contrast {ratio:.2f} < {SOFT_MIN} vs {pole}")
status_bg = palette["status_bar_bg"]
for key in ON_STATUS_BAR:
floor = STRONG_MIN if key == "status_bar_strong" else SOFT_MIN
ratio = contrast(palette[key], status_bg)
if ratio < floor:
problems.append(f"{key}={palette[key]} contrast {ratio:.2f} < {floor} vs status_bar_bg {status_bg}")
_check_fills(palette, is_light, FILLS, problems)
_check_chip(palette, problems)
assert not problems, f"{skin}.colors:\n " + "\n ".join(problems)
@pytest.mark.parametrize(("skin", "block", "palette", "is_light"), OVERLAYS, ids=OVERLAY_IDS)
def test_overlay_keys_and_fill_polarity(skin, block, palette, is_light):
unknown = palette.keys() - REQUIRED_KEYS
assert not unknown, f"{skin}.{block} has unknown keys: {sorted(unknown)}"
problems = []
_check_fills(palette, is_light, [k for k in FILLS if k in palette], problems)
if "completion_menu_current_bg" in palette and "completion_menu_bg" in palette:
_check_chip(palette, problems)
assert not problems, f"{skin}.{block}:\n " + "\n ".join(problems)
def _check_fills(palette, is_light, keys, problems):
for key in keys:
lum = luminance(palette[key])
if is_light and lum < 0.4:
problems.append(f"{key}={palette[key]} is a dark fill (lum {lum:.2f}) in a light palette")
if not is_light and lum > 0.35:
problems.append(f"{key}={palette[key]} is a light fill (lum {lum:.2f}) in a dark palette")
def _check_chip(palette, problems):
# The selection chip must remain distinguishable from the menu surface.
chip = contrast(palette["completion_menu_current_bg"], palette["completion_menu_bg"])
if chip < 1.15:
problems.append(
f"completion_menu_current_bg={palette['completion_menu_current_bg']} "
f"indistinguishable from completion_menu_bg (contrast {chip:.2f})"
)

View file

@ -3,6 +3,7 @@ import concurrent.futures
import contextlib
import contextvars
import copy
import hashlib
import inspect
import json
import logging
@ -226,6 +227,13 @@ _LONG_HANDLERS = frozenset(
"pet.thumb",
"learning.frames",
"plugins.manage",
# reload.mcp shuts down and rediscovers every MCP server — with a
# flapping server (retry loops, connect timeouts up to 120s) that can
# block for minutes. Inline it froze the reader thread: config.set,
# complete.slash, prompt.submit all sat unread and the TUI appeared
# dead after a few skin switches. The handler serializes concurrent
# reloads via _mcp_reload_lock.
"reload.mcp",
"process.list",
"projects.discover_repos",
"projects.record_repos",
@ -2400,6 +2408,10 @@ def resolve_skin() -> dict:
return {
"name": skin.name,
"colors": skin.colors,
# Paired palettes: the TUI detects the terminal's polarity and
# prefers the matching hand-tuned block over adapting `colors`.
"light_colors": skin.light_colors,
"dark_colors": skin.dark_colors,
"branding": skin.branding,
"banner_logo": skin.banner_logo,
"banner_hero": skin.banner_hero,
@ -11804,6 +11816,15 @@ def _(rid, params: dict) -> dict:
_write_config_key("display.battery", nv_b)
return _ok(rid, {"key": key, "value": "on" if nv_b else "off"})
if key == "theme":
# TUI light/dark mode pin: 'light'/'dark' beat background
# auto-detection (xterm.js hosts misreport OSC 11); 'auto' trusts it.
raw = str(value or "").strip().lower()
if raw not in {"auto", "light", "dark"}:
return _err(rid, 4002, f"unknown theme value: {value} (use auto|light|dark)")
_write_config_key("display.tui_theme", raw)
return _ok(rid, {"key": key, "value": raw})
if key == "statusbar":
raw = str(value or "").strip().lower()
display = _load_cfg().get("display")
@ -12645,6 +12666,10 @@ def _(rid, params: dict) -> dict:
if key == "compact":
on = bool((_load_cfg().get("display") or {}).get("tui_compact", False))
return _ok(rid, {"value": "on" if on else "off"})
if key == "theme":
display = _load_cfg().get("display")
raw = str(display.get("tui_theme", "auto") if isinstance(display, dict) else "auto").strip().lower()
return _ok(rid, {"value": raw if raw in {"auto", "light", "dark"} else "auto"})
if key == "statusbar":
display = _load_cfg().get("display")
raw = (
@ -12657,11 +12682,30 @@ def _(rid, params: dict) -> dict:
if key == "mtime":
cfg_path = _hermes_home / "config.yaml"
try:
return _ok(
rid, {"mtime": cfg_path.stat().st_mtime if cfg_path.exists() else 0}
)
mtime = cfg_path.stat().st_mtime if cfg_path.exists() else 0
except Exception:
return _ok(rid, {"mtime": 0})
# Revision hash of the MCP-relevant config sections. The TUI's
# config-change poller uses it to reload MCP servers only when their
# config actually changed — a /skin or /statusbar write bumps mtime
# but must not cost a multi-second MCP reconnect.
try:
cfg = _load_cfg()
# mcp_servers holds the server DEFINITIONS the classic CLI watches
# for auto-reload (cli.py::_check_config_mcp_changes) — omitting it
# meant editing a server bumped mtime but not mcp_rev, so the TUI
# skipped reload.mcp and new servers never connected until a manual
# /reload-mcp. `mcp` (settings) and `tools` (enable/disable) round
# out the MCP-relevant surface.
rev_src = json.dumps(
{"mcp": cfg.get("mcp"), "mcp_servers": cfg.get("mcp_servers"), "tools": cfg.get("tools")},
sort_keys=True,
default=str,
)
mcp_rev = hashlib.sha1(rev_src.encode()).hexdigest()[:12]
except Exception:
mcp_rev = ""
return _ok(rid, {"mtime": mtime, "mcp_rev": mcp_rev})
return _err(rid, 4002, f"unknown config key: {key}")
@ -12835,6 +12879,37 @@ def _(rid, params: dict) -> dict:
return _err(rid, 5010, str(e))
# reload.mcp runs on the RPC pool (see _LONG_HANDLERS) so a slow/flapping MCP
# server can't freeze the reader thread. Serialize reloads: overlapping
# shutdown+discover pairs from stacked config-change polls would interleave
# and leave the registry half-built. Piggyback rather than queue — a reload
# that arrives while one is running would just redo identical work.
_mcp_reload_lock = threading.Lock()
# Bumped once per SUCCESSFUL shutdown+discover. A follower that waited on the
# lock only skips the redundant reload if this advanced while it waited — i.e.
# the leader actually completed. If the leader threw (flapping server), the
# follower sees no advance and re-runs the full reload itself.
_mcp_reload_gen = 0
def _finish_reload(rid, params: dict, *, coalesced: bool) -> dict:
"""Shared tail for both reload paths: honor ``always`` (persist the
confirm opt-out) and return the ok payload."""
if bool(params.get("always", False)):
try:
from cli import save_config_value as _save_cfg
_save_cfg("approvals.mcp_reload_confirm", False)
except Exception as _exc:
logger.warning("Failed to persist mcp_reload_confirm=false: %s", _exc)
payload = {"status": "reloaded"}
if coalesced:
payload["coalesced"] = True
return _ok(rid, payload)
@method("reload.mcp")
def _(rid, params: dict) -> dict:
session = _sessions.get(params.get("session_id", ""))
@ -12889,16 +12964,16 @@ def _(rid, params: dict) -> dict:
from tools.mcp_tool import shutdown_mcp_servers, discover_mcp_tools
shutdown_mcp_servers()
discover_mcp_tools()
if session:
def _refresh_session_agent() -> None:
"""Rebuild THIS session's cached tool snapshot from the live
registry and push session.info. The agent snapshots tools once at
build and never re-reads the registry, so an explicit rebuild is
required (mirrors gateway/run.py::_execute_mcp_reload). Runs under
_mcp_reload_lock so the registry it reads can't be torn down by a
concurrent reload mid-refresh."""
if not session:
return
agent = session["agent"]
# Rebuild the cached agent's tool snapshot so the current session
# picks up added/removed MCP tools without `/new` (which discards
# history). The agent snapshots tools once at build and never
# re-reads the registry, so an explicit rebuild is required here.
# The user already consented to the prompt-cache invalidation via
# the confirm gate above. Mirrors gateway/run.py::_execute_mcp_reload.
try:
from tools.mcp_tool import refresh_agent_mcp_tools
@ -12914,22 +12989,49 @@ def _(rid, params: dict) -> dict:
"Failed to refresh cached agent tools after /reload-mcp: %s",
_exc,
)
_emit(
"session.info",
params.get("session_id", ""),
_session_info(agent, session),
)
_emit("session.info", params.get("session_id", ""), _session_info(agent, session))
# Honor `always=true` by persisting the opt-out to config.
if bool(params.get("always", False)):
global _mcp_reload_gen
def _do_full_reload() -> None:
"""shutdown+discover+refresh under the lock, then mark a completed
generation. The lock spans the refresh too: releasing after
discover would let a second reload tear the registry down while
this one is still reading it to rebuild the session snapshot."""
global _mcp_reload_gen
shutdown_mcp_servers()
discover_mcp_tools()
_refresh_session_agent()
_mcp_reload_gen += 1
# Serialize reloads. The LEADER (won the non-blocking acquire) runs the
# full reload. A FOLLOWER (lock busy) snapshots the generation, waits,
# then — still holding the lock — checks whether a reload actually
# COMPLETED while it waited: if so it just refreshes its own agent
# against the fresh registry (coalesced); if the leader threw (flapping
# server, no generation advance) it re-runs the full reload itself, so
# a failed leader can never leave a follower reporting a bogus success
# over an empty/partial registry.
if _mcp_reload_lock.acquire(blocking=False):
try:
from cli import save_config_value as _save_cfg
_do_full_reload()
finally:
_mcp_reload_lock.release()
_save_cfg("approvals.mcp_reload_confirm", False)
except Exception as _exc:
logger.warning("Failed to persist mcp_reload_confirm=false: %s", _exc)
return _finish_reload(rid, params, coalesced=False)
return _ok(rid, {"status": "reloaded"})
gen_before = _mcp_reload_gen
with _mcp_reload_lock:
if _mcp_reload_gen > gen_before:
_refresh_session_agent()
coalesced = True
else:
_do_full_reload()
coalesced = False
return _finish_reload(rid, params, coalesced=coalesced)
except Exception as e:
return _err(rid, 5015, str(e))

View file

@ -8,6 +8,7 @@
"start": "tsx src/entry.tsx",
"build": "node scripts/build.mjs",
"build:ink": "npm run build --prefix packages/hermes-ink",
"visual": "FORCE_COLOR=3 COLORTERM=truecolor tsx scripts/visual/render.tsx && electron scripts/visual/shot.mjs",
"typecheck": "tsc --noEmit -p tsconfig.json",
"lint": "eslint src/ packages/",
"lint:fix": "eslint src/ packages/ --fix",

View file

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

View file

@ -21,8 +21,15 @@ import {
import reconciler from '../reconciler.js'
import { clearSelection, finishSelection, hasSelection, type SelectionState, startSelection } from '../selection.js'
import { getTerminalFocused, setTerminalFocused } from '../terminal-focus-state.js'
import { decrqm, TerminalQuerier, xtversion } from '../terminal-querier.js'
import { isXtermJs, setXtversionName, supportsExtendedKeys } from '../terminal.js'
import { decrqm, oscColor, TerminalQuerier, xtversion } from '../terminal-querier.js'
import {
isXtermJs,
parseOscColor,
setTerminalBackgroundHex,
setTerminalForegroundHex,
setXtversionName,
supportsExtendedKeys
} from '../terminal.js'
import {
DISABLE_KITTY_KEYBOARD,
DISABLE_MODIFY_OTHER_KEYS,
@ -336,13 +343,42 @@ export default class App extends PureComponent<Props, State> {
// init sequence completes — avoids interleaving with alt-screen/mouse
// tracking enable writes that may happen in the same render cycle.
setImmediate(() => {
void Promise.all([this.querier.send(xtversion()), this.querier.flush()]).then(([r]) => {
// OSC 11 + OSC 10 ride the same batch: the terminal's actual
// background drives light/dark theme detection where env heuristics
// (COLORFGBG, TERM_PROGRAM) are blind — notably xterm.js hosts. The
// FOREGROUND is the polarity tiebreaker for transparent profiles:
// those report the unset-default background (pure black) but the
// theme's real foreground, whose luminance reveals the pole.
void Promise.all([
this.querier.send(xtversion()),
this.querier.send(oscColor(11)),
this.querier.send(oscColor(10)),
this.querier.flush()
]).then(([r, bg, fg]) => {
if (r) {
setXtversionName(r.name)
logForDebugging(`XTVERSION: terminal identified as "${r.name}"`)
} else {
logForDebugging('XTVERSION: no reply (terminal ignored query)')
}
const bgHex = bg ? parseOscColor(bg.data) : undefined
const fgHex = fg ? parseOscColor(fg.data) : undefined
// Background first: a trusted OSC-11 answer settles polarity
// outright, so the foreground listener (the transparent-profile
// tiebreaker) sees it already resolved and stays silent.
if (bgHex) {
setTerminalBackgroundHex(bgHex)
logForDebugging(`OSC11: terminal background is ${bgHex}`)
} else {
logForDebugging('OSC11: no reply (terminal ignored query)')
}
if (fgHex) {
setTerminalForegroundHex(fgHex)
logForDebugging(`OSC10: terminal foreground is ${fgHex}`)
}
})
})

View file

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

View file

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

View file

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

View file

@ -0,0 +1,309 @@
/* Visual self-verification tool: `npm run visual` renders real TUI surfaces
* across theme x background scenes to /tmp/tui-visual.html, then shot.mjs
* screenshots it to /tmp/tui-visual.png for eyeball + agent review.
*
* Original note: : render real TUI surfaces with ANSI colors intact,
* convert to HTML on the actual background, and screenshot in a browser. */
process.env.FORCE_COLOR = '3'
process.env.COLORTERM = 'truecolor'
import '../../src/lib/forceTruecolor.js'
import { writeFileSync } from 'fs'
import { PassThrough } from 'stream'
import { Box, renderSync, Text } from '@hermes/ink'
import React, { type ReactElement } from 'react'
import { GatewayProvider } from '../../src/app/gatewayContext.js'
import { patchOverlayState, resetOverlayState } from '../../src/app/overlayStore.js'
import { patchUiState, resetUiState } from '../../src/app/uiStore.js'
import { FloatingOverlays } from '../../src/components/appOverlays.js'
import { Banner, SessionPanel } from '../../src/components/branding.js'
import { fromSkin, type Theme } from '../../src/theme.js'
import type { SessionInfo } from '../../src/types.js'
const noop = () => {}
const pending = () => new Promise<never>(() => {})
const fakeGateway = { gw: { notify: noop, off: noop, on: noop, request: pending }, rpc: pending } as any
const SLATE = {
banner_border: '#4169e1',
banner_title: '#7eb8f6',
banner_accent: '#8EA8FF',
banner_dim: '#4b5563',
banner_text: '#c9d1d9',
ui_accent: '#7eb8f6',
ui_label: '#8EA8FF',
ui_ok: '#63D0A6',
ui_error: '#F7A072',
ui_warn: '#e6a855',
prompt: '#c9d1d9',
session_label: '#7eb8f6',
session_border: '#545E6B',
status_bar_bg: '#151C2F',
status_bar_text: '#C9D1D9'
}
// The regenerated slate light_colors block from hermes_cli/skin_engine.py
// (relight recipe: vivid hue-preserved accents, airy capped-saturation text,
// darker calm dims).
const info: SessionInfo = {
cwd: '/Users/brooklyn/www/hermes-agent',
mcp_servers: [{ connected: true, name: 'figma', tools: 12, transport: 'sse' }],
model: 'claude-opus-4.8-fast',
skills: {
devops: ['docker', 'kubernetes', 'terraform'],
github: ['pr-review', 'issue-triage'],
productivity: ['powerpoint', 'excel', 'notion-sync']
},
tools: {
browser: ['browser_back', 'browser_click', 'browser_console', 'browser_get_images'],
clarify: ['clarify'],
code_execution: ['execute_code'],
cronjob: ['cronjob'],
delegation: ['delegate_task'],
file: ['patch', 'read_file', 'search_files', 'write_file']
},
update_behind: 1,
version: '3.2.1'
}
const completions = [
{ display: '/new', meta: 'Start a new session (fresh session ID + history)', text: '/new' },
{ display: '/reset', meta: 'Start a new session (alias for /new)', text: '/reset' },
{ display: '/clear', meta: 'Clear screen and start a new session', text: '/clear' },
{ display: '/redraw', meta: 'Force a full UI repaint', text: '/redraw' },
{ display: '/history', meta: 'Show conversation history', text: '/history' }
]
function renderAnsi(node: ReactElement, columns: number): string {
const stdout = new PassThrough()
const stdin = new PassThrough()
const stderr = new PassThrough()
let output = ''
;(process.stdout as unknown as { columns: number }).columns = columns
Object.assign(stdout, { columns, isTTY: false, rows: 60 })
Object.assign(stdin, {
isTTY: true,
pause: noop,
ref: noop,
resume: noop,
setEncoding: noop,
setRawMode: noop,
unref: noop
})
Object.assign(stderr, { isTTY: false })
stdout.on('data', chunk => {
output += chunk.toString()
})
const instance = renderSync(
<GatewayProvider value={fakeGateway}>
<Box flexDirection="column" width={columns}>{node}</Box>
</GatewayProvider>,
{
exitOnCtrlC: false,
patchConsole: false,
stderr: stderr as unknown as NodeJS.WriteStream,
stdin: stdin as unknown as NodeJS.ReadStream,
stdout: stdout as unknown as NodeJS.WriteStream
}
)
instance.unmount()
instance.cleanup()
return output
}
// ── ANSI → HTML (handles ink's SGR set: 38;2/48;2 truecolor, named resets, bold/dim/italic/inverse) ──
const escapeHtml = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
function ansiToHtml(raw: string, defaultFg: string, defaultBg: string): string {
let fg = defaultFg
let bg = defaultBg
let bold = false
let dim = false
let italic = false
let inverse = false
let html = ''
const openSpan = () => {
const f = inverse ? bg : fg
const b = inverse ? fg : bg
const styles = [`color:${f}`]
if (b !== defaultBg || inverse) {
styles.push(`background-color:${b}`)
}
if (bold) {
styles.push('font-weight:bold')
}
if (dim) {
styles.push('opacity:0.55')
}
if (italic) {
styles.push('font-style:italic')
}
return `<span style="${styles.join(';')}">`
}
// eslint-disable-next-line no-control-regex
const parts = raw.split(/(\x1b\[[0-9;]*m)/)
html += openSpan()
for (const part of parts) {
// eslint-disable-next-line no-control-regex
const m = /^\x1b\[([0-9;]*)m$/.exec(part)
if (!m) {
// Drop non-SGR escapes (cursor moves etc.) — renderSync output for a
// static frame is line-oriented, so this is safe for inspection.
// eslint-disable-next-line no-control-regex
html += escapeHtml(part.replace(/\x1b\[[^m]*[A-Za-z]/g, ''))
continue
}
const codes = (m[1] || '0').split(';').map(Number)
for (let i = 0; i < codes.length; i++) {
const c = codes[i]!
if (c === 0) {
fg = defaultFg
bg = defaultBg
bold = dim = italic = inverse = false
} else if (c === 1) {bold = true}
else if (c === 2) {dim = true}
else if (c === 3) {italic = true}
else if (c === 7) {inverse = true}
else if (c === 22) { bold = false; dim = false }
else if (c === 23) {italic = false}
else if (c === 27) {inverse = false}
else if (c === 39) {fg = defaultFg}
else if (c === 49) {bg = defaultBg}
else if (c === 38 && codes[i + 1] === 2) {
fg = `rgb(${codes[i + 2]},${codes[i + 3]},${codes[i + 4]})`
i += 4
} else if (c === 48 && codes[i + 1] === 2) {
bg = `rgb(${codes[i + 2]},${codes[i + 3]},${codes[i + 4]})`
i += 4
} else if (c === 38 && codes[i + 1] === 5) {
fg = `var(--a${codes[i + 2]}, #888)`
i += 2
} else if (c === 48 && codes[i + 1] === 5) {
bg = `var(--a${codes[i + 2]}, #888)`
i += 2
}
}
html += `</span>${openSpan()}`
}
return html + '</span>'
}
// ── Scenes ──
interface Scene {
bg: string
fg: string
name: string
theme: Theme
}
const setup = (bgHex: string) => {
process.env.HERMES_TUI_BACKGROUND = bgHex
resetOverlayState()
resetUiState()
}
const scenes: Scene[] = []
const addScene = (name: string, bgHex: string, skin: Record<string, string>) => {
setup(bgHex)
const theme = fromSkin(skin, {})
scenes.push({ bg: bgHex, fg: theme.color.text, name, theme })
}
addScene('default · dark terminal', '#101014', {})
addScene('default · light terminal (Cursor)', '#ffffff', {})
addScene('slate · dark terminal', '#101014', SLATE)
addScene('slate · light terminal (raw palette + display shim)', '#ffffff', SLATE)
let page = `<!doctype html><meta charset="utf-8"><body style="margin:0;background:#666;font:13px/1.35 Menlo,monospace"><div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;padding:16px">`
for (const scene of scenes) {
setup(scene.bg)
patchUiState({ sid: 'd2a6ecf8', theme: scene.theme })
const intro = renderAnsi(
<Box flexDirection="column">
<Banner maxWidth={86} t={scene.theme} />
<SessionPanel info={info} maxWidth={86} sid="d2a6ecf8" t={scene.theme} />
</Box>,
88
)
patchOverlayState({})
const comps = renderAnsi(
<Box flexDirection="column" height={10} position="relative" width={88}>
<Box flexGrow={1} />
<FloatingOverlays
cols={88}
compIdx={0}
completions={completions}
onActiveSessionClose={pending}
onActiveSessionSelect={noop}
onModelSelect={noop}
onNewLiveSession={noop}
onNewPromptSession={noop}
onResumeSelect={noop}
pagerPageSize={8}
/>
</Box>,
88
)
const statusLine = renderAnsi(
<Box flexDirection="column">
<Text>
<Text color={scene.theme.color.statusGood}> ready </Text>
<Text color={scene.theme.color.muted}>| opus 4.8 fast | 4s | voice off</Text>
</Text>
<Text>
<Text color={scene.theme.color.muted}>{scene.theme.brand.prompt} </Text>
<Text backgroundColor={scene.theme.color.muted} color={scene.bg}>T</Text>
<Text color={scene.theme.color.muted}>ry &quot;fix the lint errors&quot;</Text>
</Text>
</Box>,
88
)
page += `<div style="background:${scene.bg};color:${scene.fg};padding:14px;border-radius:6px">`
page += `<div style="font:bold 12px sans-serif;opacity:.6;margin-bottom:8px;color:${scene.fg}">${scene.name}</div>`
page += `<pre style="margin:0;white-space:pre">${ansiToHtml(intro, scene.fg, scene.bg)}</pre>`
page += `<pre style="margin:8px 0 0;white-space:pre">${ansiToHtml(comps, scene.fg, scene.bg)}</pre>`
page += `<pre style="margin:8px 0 0;white-space:pre">${ansiToHtml(statusLine, scene.fg, scene.bg)}</pre>`
page += `</div>`
}
page += '</div></body>'
writeFileSync('/tmp/tui-visual.html', page)
console.log('wrote /tmp/tui-visual.html')
process.exit(0)

View file

@ -0,0 +1,23 @@
// Screenshot /tmp/tui-visual.html with the repo's Electron (offscreen).
import { app, BrowserWindow } from 'electron'
import { writeFileSync } from 'fs'
app.disableHardwareAcceleration()
app.whenReady().then(async () => {
const win = new BrowserWindow({
height: 2100,
show: false,
webPreferences: { offscreen: true },
width: 1500
})
await win.loadFile('/tmp/tui-visual.html')
await new Promise(r => setTimeout(r, 700))
const image = await win.webContents.capturePage()
writeFileSync('/tmp/tui-visual.png', image.toPNG())
console.log('wrote /tmp/tui-visual.png')
app.quit()
})

View file

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

View file

@ -723,6 +723,40 @@ describe('createGatewayEventHandler', () => {
expect(ctx.gateway.rpc).not.toHaveBeenCalled()
})
it('picks the polarity-matching paired palette from gateway.ready skins', async () => {
const appended: Msg[] = []
const skin = {
colors: { banner_title: '#00FF88', banner_text: '#FFF8DC' },
light_colors: { banner_title: '#8B0000', banner_text: '#22201C' }
}
// Dark terminal (clean env): the dark-authored `colors` block wins.
vi.stubEnv('HERMES_TUI_BACKGROUND', '')
createGatewayEventHandler(buildCtx(appended))({ payload: skin, type: 'skin.changed' } as any)
expect(getUiState().theme.color.primary).toBe('#00FF88')
// Light terminal: the hand-tuned light_colors block wins over adaptation.
vi.stubEnv('HERMES_TUI_BACKGROUND', '#ffffff')
createGatewayEventHandler(buildCtx(appended))({ payload: skin, type: 'skin.changed' } as any)
expect(getUiState().theme.color.primary).toBe('#8B0000')
vi.unstubAllEnvs()
})
it('infers polarity from the OSC-10 foreground only when the answer is decisive', async () => {
const { polarityBackgroundFromForeground } = await import('../app/createGatewayEventHandler.js')
// Bright foreground = dark theme; dark foreground = light theme.
expect(polarityBackgroundFromForeground('#cccccc')).toBe('#1e1e1e')
expect(polarityBackgroundFromForeground('#333333')).toBe('#ffffff')
// Unset-default fingerprints and ambiguous mid-grays commit nothing.
expect(polarityBackgroundFromForeground('#000000')).toBeUndefined()
expect(polarityBackgroundFromForeground('#ffffff')).toBeUndefined()
expect(polarityBackgroundFromForeground('#808080')).toBeUndefined()
expect(polarityBackgroundFromForeground('not-a-color')).toBeUndefined()
})
it('on gateway.ready with no STARTUP_RESUME_ID and auto_resume off, forges a new session', async () => {
const appended: Msg[] = []
const newSession = vi.fn()

View file

@ -70,6 +70,22 @@ describe('createSlashHandler', () => {
expect(getOverlayState().sessions).toBe(true)
})
it('opens the grid-test overlay locally', () => {
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/grid-test 6x4')).toBe(true)
expect(getOverlayState().gridTest).toMatchObject({ cols: 6, nested: false, rows: 4, streams: false })
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
})
it('opens the grid-test streams demo via /grid-test streams', () => {
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/grid-test streams')).toBe(true)
expect(getOverlayState().gridTest).toMatchObject({ streamFocus: 0, streamMain: 0, streams: true })
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
})
it('handles /redraw locally without slash worker fallback', () => {
const ctx = buildCtx()

View file

@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import { renderToScreen } from '../../packages/hermes-ink/src/ink/render-to-screen.js'
import { cellAtIndex } from '../../packages/hermes-ink/src/ink/screen.js'
import { ShimmerRows, shimmerSegments } from '../components/loaders.js'
describe('ShimmerRows leniency (agent-authored calls)', () => {
it('accepts a bare row COUNT and derives widths — the generated-code shape', async () => {
const { createElement } = await import('react')
const { screen, height } = renderToScreen(
createElement(ShimmerRows, { rows: 3, width: 20, t: { color: { completionBg: '#1a1a2e', label: '#DAA520', muted: '#B8860B' } } }),
30
)
expect(height).toBe(3)
// Row 0 renders block cells, not a crash.
expect(cellAtIndex(screen, 0).char).toBe('▁')
})
})
describe('shimmerSegments', () => {
it('always partitions the full width', () => {
for (let phase = -40; phase < 80; phase++) {
const [pre, band, post] = shimmerSegments(20, phase)
expect(pre + band + post).toBe(20)
expect(Math.min(pre, band, post)).toBeGreaterThanOrEqual(0)
}
})
it('sweeps: enters from the left edge, exits off the right, then wraps', () => {
const bandAt = (phase: number) => shimmerSegments(10, phase, 4)
expect(bandAt(0)).toEqual([10, 0, 0]) // band fully off-left
expect(bandAt(1)).toEqual([0, 1, 9]) // entering
expect(bandAt(7)).toEqual([3, 4, 3]) // mid-sweep
expect(bandAt(13)).toEqual([9, 1, 0]) // exiting
expect(bandAt(14)).toEqual([10, 0, 0]) // gone → next cycle re-enters
expect(bandAt(15)).toEqual([0, 1, 9])
})
it('negative phases (row stagger) wrap instead of vanishing', () => {
const [pre, band, post] = shimmerSegments(10, -3, 4)
expect(pre + band + post).toBe(10)
})
})

View file

@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { clampOverlayWidth } from '../components/overlayPrimitives.js'
describe('clampOverlayWidth', () => {
it('prefers preferred, capped by maxWidth', () => {
expect(clampOverlayWidth(60)).toBe(60)
expect(clampOverlayWidth(60, 40)).toBe(40)
expect(clampOverlayWidth(30, 80)).toBe(30)
})
it('honors caps BELOW the usability floor instead of overflowing the cell', () => {
// Copilot review on #20379: a 20-col grid cell must get 20, not 24.
expect(clampOverlayWidth(60, 20)).toBe(20)
expect(clampOverlayWidth(60, 1)).toBe(1)
})
it('keeps the floor when the cap allows it', () => {
expect(clampOverlayWidth(10, 80)).toBe(24)
expect(clampOverlayWidth(10)).toBe(24)
})
})

View file

@ -200,13 +200,29 @@ describe('fromSkin', () => {
expect(fromSkin({ banner_title: '#FF0000' }, {}).color.accent).toBe(DEFAULT_THEME.color.accent)
})
it('derives completion current background from resolved completion background', async () => {
const { fromSkin } = await importThemeWithCleanEnv()
it('derives completion current background from resolved completion background (polarity-compatible)', async () => {
// Light terminal + light-authored menu fill: the skin's fill is honored
// and the current-row derivation mixes off it.
const { fromSkin } = await importThemeWithEnv({ HERMES_TUI_BACKGROUND: '#ffffff' })
const theme = fromSkin({ banner_accent: '#000000', completion_menu_bg: '#ffffff' }, {})
expect(theme.color.completionBg).toBe('#ffffff')
expect(theme.color.completionCurrentBg).toBe('#bfbfbf')
// Active row = authored surface mixed toward the accent (ladder knob).
expect(theme.color.completionCurrentBg).toBe('#c7c7c7')
})
it('rejects wrong-polarity fills even when skin-authored (terminal owns the canvas)', async () => {
// Dark terminal + white menu fill: unlike the desktop app, the TUI cannot
// paint its own canvas, so cross-polarity fills fall back to the derived
// ladder values, which are mixed from the real background and therefore
// polarity-correct by construction.
const { fromSkin } = await importThemeWithCleanEnv()
const theme = fromSkin({ banner_accent: '#000000', completion_menu_bg: '#ffffff' }, {})
expect(luminance(theme.color.completionBg)).toBeLessThanOrEqual(0.35)
expect(luminance(theme.color.completionCurrentBg)).toBeLessThanOrEqual(0.35)
})
it('uses active completion color as the selection highlight fallback', async () => {
@ -286,11 +302,16 @@ describe('fromSkin', () => {
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' })
it('keeps truecolor light Apple Terminal in truecolor (adapting, not ansi256-bucketing)', async () => {
const { contrastRatio, fromSkin } = await importThemeWithEnv({ COLORTERM: 'truecolor', TERM_PROGRAM: 'Apple_Terminal' })
const theme = fromSkin({ banner_text: '#FFF8DC' }, {})
expect(theme.color.text).toBe('#FFF8DC')
// No ansi256 bucketing on truecolor terminals — a truly invisible cream
// (1.08:1 on white) still gets the display shim's gentle light-mode rescue
// (floor 1.18: enough to make near-white text appear, not enough to crush
// the vivid golds into mud).
expect(theme.color.text).toMatch(/^#[0-9a-f]{6}$/i)
expect(contrastRatio(theme.color.text, '#ffffff')!).toBeGreaterThanOrEqual(1.18)
})
it('normalizes Apple Terminal names before matching', async () => {
@ -311,7 +332,203 @@ describe('fromSkin', () => {
const { fromSkin } = await importThemeWithCleanEnv()
const { color } = fromSkin({ ui_ok: '#008000' }, {})
expect(color.ok).toBe('#008000')
expect(color.statusGood).toBe('#008000')
// The exact value may be contrast-lifted against the background; the
// contract is the cascade (ok drives statusGood) and the hue surviving.
expect(color.statusGood).toBe(color.ok)
expect(color.ok).toMatch(/^#[0-9a-f]{6}$/i)
expect(luminance(color.ok)).toBeGreaterThan(0)
})
})
// Rec. 709-ish relative luminance, local to the test so assertions are
// independent of the implementation under test.
const luminance = (hex: string): number => {
const n = parseInt(hex.replace('#', ''), 16)
const channel = (v: number) => {
const c = v / 255
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
}
return 0.2126 * channel((n >> 16) & 0xff) + 0.7152 * channel((n >> 8) & 0xff) + 0.0722 * channel(n & 0xff)
}
// The bundled slate skin's actual color block — dark-authored (pale pastels,
// no completion/selection backgrounds defined).
const SLATE_COLORS = {
banner_accent: '#8EA8FF',
banner_border: '#4169e1',
banner_dim: '#4b5563',
banner_text: '#c9d1d9',
banner_title: '#7eb8f6',
prompt: '#c9d1d9',
session_border: '#4b5563',
session_label: '#7eb8f6',
ui_accent: '#7eb8f6',
ui_error: '#F7A072',
ui_label: '#8EA8FF',
ui_ok: '#63D0A6',
ui_warn: '#e6a855'
}
// Max per-channel deviation between two hexes.
const channelDelta = (a: string, b: string) => {
const pa = parseInt(a.replace('#', ''), 16)
const pb = parseInt(b.replace('#', ''), 16)
return Math.max(
Math.abs(((pa >> 16) & 0xff) - ((pb >> 16) & 0xff)),
Math.abs(((pa >> 8) & 0xff) - ((pb >> 8) & 0xff)),
Math.abs((pa & 0xff) - (pb & 0xff))
)
}
describe('derived tone ladder', () => {
it('reproduces the original hand-tuned tones from seeds (reverse-engineered knobs)', async () => {
// The ladder's knobs were grid-search fitted so the MATH lands on the
// pre-refactor hand-tuned literals. Contract: every derived tone stays
// within a-few-RGB-units of the original (imperceptible), so knob edits
// that drift the classic look fail here instead of shipping as vibes.
const dark = await importThemeWithCleanEnv()
const light = await importThemeWithEnv({ HERMES_TUI_BACKGROUND: '#ffffff' })
const cases: Array<[string, string, string]> = [
[dark.DARK_THEME.color.muted, '#CC9B1F', 'dark muted'],
[dark.DARK_THEME.color.label, '#DAA520', 'dark label'],
[dark.DARK_THEME.color.statusFg, '#C0C0C0', 'dark statusFg'],
[dark.DARK_THEME.color.completionBg, '#1a1a2e', 'dark surface'],
[dark.DARK_THEME.color.completionCurrentBg, '#333355', 'dark chip'],
[dark.DARK_THEME.color.selectionBg, '#3a3a55', 'dark selection'],
// Light canon = liftForContrast(dark literal, white, 4.5): the exact
// colors xterm's minimumContrastRatio rendered on light hosts.
[light.LIGHT_THEME.color.muted, '#946C08', 'light muted'],
[light.LIGHT_THEME.color.statusFg, '#6F6F6F', 'light statusFg'],
[light.LIGHT_THEME.color.completionBg, '#F5F5F5', 'light surface'],
[light.LIGHT_THEME.color.completionCurrentBg, '#e0d1bf', 'light chip'],
[light.LIGHT_THEME.color.selectionBg, '#D4E4F7', 'light selection']
]
for (const [got, original, label] of cases) {
expect(channelDelta(got, original), `${label}: ${got} vs original ${original}`).toBeLessThanOrEqual(8)
}
})
it('derives dim/secondary tones from the skin identity, not another palette', async () => {
const { fromSkin } = await importThemeWithCleanEnv()
// A seeds-only skin (no dim/label/menu keys authored at all).
const { color } = fromSkin(
{ banner_accent: '#DD4A3A', banner_text: '#F1E6CF', banner_title: '#C7A96B' },
{}
)
// Muted recedes from THIS skin's text toward the background with an
// accent tint — a red-family derivative, never another skin's gold.
expect(color.muted).not.toBe(color.text)
expect(luminance(color.muted)).toBeLessThan(luminance(color.text))
const rgb = (hex: string) => [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16))
const [mr, , mb] = rgb(color.muted)
expect(mr).toBeGreaterThan(mb!)
// The active-row chip is the surface tinted with the skin accent —
// redder than the plain surface.
const [sr] = rgb(color.completionBg)
expect(rgb(color.completionCurrentBg)[0]).toBeGreaterThan(sr!)
})
it('authored tones still override the ladder', async () => {
const { fromSkin } = await importThemeWithCleanEnv()
const { color } = fromSkin({ banner_dim: '#AA8844', banner_text: '#F1E6CF' }, {})
expect(color.muted).toBe('#AA8844')
expect(color.sessionLabel).toBe('#AA8844')
})
})
describe('background-aware adaptation (OSC-11 light terminals)', () => {
it('renders a dark-authored skin on light like minimumContrastRatio hosts do (the standardized look)', async () => {
const { contrastRatio, fromSkin } = await importThemeWithEnv({ HERMES_TUI_BACKGROUND: '#ffffff' })
const { color } = fromSkin(SLATE_COLORS, {})
// The authored palette IS the design: slate's airy pastels (~1.5:1) pass
// through BYTE-IDENTICAL — that receded look is the standardized
// rendering, not a washout to fix.
expect(color.text.toLowerCase()).toBe('#c9d1d9')
expect(color.accent.toLowerCase()).toBe('#7eb8f6')
expect(color.muted.toLowerCase()).toBe('#4b5563')
// Light mode renders the authored palette essentially RAW: a transparent
// terminal (the common Cursor case) applies no contrast lift of its own,
// and the beloved classic look is the vivid palette, not a WCAG-darkened
// one. Foregrounds only clear the near-invisible floor (1.18).
for (const key of ['text', 'prompt', 'accent', 'label', 'primary', 'muted', 'border'] as const) {
expect(contrastRatio(color[key], '#ffffff'), `${key} ${color[key]}`).toBeGreaterThanOrEqual(1.18)
}
// Semantic alert colors carry meaning — firmer floor, still gentle on light.
for (const key of ['ok', 'error', 'warn', 'statusGood', 'statusCritical'] as const) {
expect(contrastRatio(color[key], '#ffffff'), `${key} ${color[key]}`).toBeGreaterThanOrEqual(1.6)
}
// Background roles the skin never defined must be light-polarity fills,
// not the dark base's navy.
for (const key of ['completionBg', 'completionCurrentBg', 'statusBg', 'selectionBg'] as const) {
expect(luminance(color[key]), `${key} ${color[key]}`).toBeGreaterThanOrEqual(0.4)
}
})
it('rescues near-invisible colors with a hue-preserving multiplicative lift', async () => {
const { contrastRatio, fromSkin } = await importThemeWithEnv({ HERMES_TUI_BACKGROUND: '#ffffff' })
// The default dark cream (#FFF8DC, 1.08:1 on white) is genuinely invisible.
const { color } = fromSkin({ banner_text: '#FFF8DC' }, {})
expect(color.text.toLowerCase()).not.toBe('#fff8dc')
expect(contrastRatio(color.text, '#ffffff')!).toBeGreaterThanOrEqual(1.18)
// Multiplicative lift preserves channel ordering (warm stays warm).
const [r, g, b] = [1, 3, 5].map(i => parseInt(color.text.slice(i, i + 2), 16))
expect(r).toBeGreaterThanOrEqual(g!)
expect(g).toBeGreaterThanOrEqual(b!)
})
it('leaves the same skin untouched on a dark background', async () => {
const { fromSkin } = await importThemeWithEnv({ HERMES_TUI_BACKGROUND: '#1e1e2e' })
const { color } = fromSkin(SLATE_COLORS, {})
expect(color.text).toBe('#c9d1d9')
expect(color.accent).toBe('#7eb8f6')
expect(luminance(color.completionBg)).toBeLessThanOrEqual(0.35)
})
it('empty skin on a light background resolves to the light base palette', async () => {
const { fromSkin, LIGHT_THEME } = await importThemeWithEnv({ HERMES_TUI_BACKGROUND: '#ffffff' })
expect(fromSkin({}, {}).color).toEqual(LIGHT_THEME.color)
})
it('base palettes are fixed points of the adaptation', async () => {
const dark = await importThemeWithCleanEnv()
expect(dark.fromSkin({}, {}).color).toEqual(dark.DARK_THEME.color)
const light = await importThemeWithEnv({ HERMES_TUI_BACKGROUND: '#ffffff' })
expect(light.fromSkin({}, {}).color).toEqual(light.LIGHT_THEME.color)
})
it('defaultThemeForCurrentBackground follows a late HERMES_TUI_BACKGROUND write', async () => {
const { DARK_THEME, DEFAULT_THEME, defaultThemeForCurrentBackground, LIGHT_THEME } = await importThemeWithCleanEnv()
// Module loaded dark (clean env)…
expect(DEFAULT_THEME.color.completionBg).toBe(DARK_THEME.color.completionBg)
expect(luminance(DEFAULT_THEME.color.completionBg)).toBeLessThanOrEqual(0.35)
// …then the OSC-11 answer lands and is cached into the env slot.
expect(defaultThemeForCurrentBackground({ HERMES_TUI_BACKGROUND: '#ffffff' }).color).toEqual(LIGHT_THEME.color)
})
})

View file

@ -0,0 +1,246 @@
import { describe, expect, it } from 'vitest'
import { type GridTrackSize, layoutGridAreas, layoutWidgetGrid, resolveGridTracks } from '../lib/widgetGrid.js'
describe('layoutWidgetGrid', () => {
it('falls back to a single column on narrow widths', () => {
const layout = layoutWidgetGrid({
items: [{ id: 'a' }, { id: 'b' }],
maxColumns: 3,
minColumnWidth: 40,
width: 35
})
expect(layout.columnCount).toBe(1)
expect(layout.columns).toEqual([35])
expect(layout.rows).toEqual([[{ col: 0, id: 'a', span: 1, width: 35 }], [{ col: 0, id: 'b', span: 1, width: 35 }]])
})
it('packs spans left-to-right and wraps to the next row', () => {
const layout = layoutWidgetGrid({
gap: 2,
items: [
{ id: 'a', span: 1 },
{ id: 'b', span: 2 },
{ id: 'c', span: 1 }
],
maxColumns: 3,
minColumnWidth: 30,
width: 100
})
expect(layout.columnCount).toBe(3)
expect(layout.columns).toEqual([32, 32, 32])
expect(layout.rows).toEqual([
[
{ col: 0, id: 'a', span: 1, width: 32 },
{ col: 1, id: 'b', span: 2, width: 66 }
],
[{ col: 0, id: 'c', span: 1, width: 32 }]
])
})
it('clamps spans to available columns', () => {
const layout = layoutWidgetGrid({
gap: 1,
items: [{ id: 'huge', span: 9 }],
maxColumns: 2,
minColumnWidth: 20,
width: 50
})
expect(layout.columnCount).toBe(2)
expect(layout.rows[0]?.[0]).toEqual({
col: 0,
id: 'huge',
span: 2,
width: 50
})
})
it('honors an exact column count when the grid has room', () => {
const layout = layoutWidgetGrid({
columns: 4,
gap: 1,
items: Array.from({ length: 8 }, (_, idx) => ({ id: `cell-${idx}` })),
width: 43
})
expect(layout.columnCount).toBe(4)
expect(layout.columns).toEqual([10, 10, 10, 10])
expect(layout.rows).toHaveLength(2)
})
it('renders sparse explicit starts without collapsing holes', () => {
const layout = layoutWidgetGrid({
columns: 4,
gap: 1,
items: [
{ colStart: 0, id: 'a' },
{ colStart: 2, id: 'b' },
{ colStart: 3, id: 'c' }
],
width: 43
})
expect(layout.rows).toEqual([
[
{ col: 0, id: 'a', span: 1, width: 10 },
{ col: 2, id: 'b', span: 1, width: 10 },
{ col: 3, id: 'c', span: 1, width: 10 }
]
])
})
})
describe('resolveGridTracks', () => {
it('splits equal fr tracks with the remainder spread left-to-right', () => {
expect(resolveGridTracks(10, 0, [{ fr: 1 }, { fr: 1 }, { fr: 1 }])).toEqual([4, 3, 3])
})
it('gives fixed tracks their size and shares the leftover by weight', () => {
// usable = 40 - 2*1 = 38; fixed 10; leftover 28 over 1fr+2fr.
expect(resolveGridTracks(40, 1, [10, { fr: 1 }, { fr: 2 }])).toEqual([10, 10, 18])
})
it('pins tracks that fall below their min and re-solves the rest', () => {
expect(resolveGridTracks(20, 0, [{ fr: 1 }, { fr: 1, min: 15 }])).toEqual([5, 15])
})
it('never lets the sum exceed the usable axis when tracks fit at their minimums', () => {
const cases: Array<{ gap: number; total: number; tracks: GridTrackSize[] }> = [
{ gap: 1, total: 80, tracks: [12, { fr: 1 }, { fr: 3, min: 20 }, 6] },
{ gap: 2, total: 33, tracks: [{ fr: 1 }, { fr: 1 }, { fr: 1 }, { fr: 1 }] },
{ gap: 0, total: 9, tracks: [4, 4, { fr: 1 }] }
]
for (const { gap, total, tracks } of cases) {
const sizes = resolveGridTracks(total, gap, tracks)
const usable = total - gap * (tracks.length - 1)
expect(sizes.reduce((acc, s) => acc + s, 0)).toBeLessThanOrEqual(Math.max(tracks.length, usable))
expect(sizes.every(s => s >= 1)).toBe(true)
}
})
it('shaves overflow off trailing tracks when fixed sizes exceed the axis', () => {
const sizes = resolveGridTracks(20, 0, [15, 15, { fr: 1 }])
expect(sizes.reduce((acc, s) => acc + s, 0)).toBeLessThanOrEqual(20)
expect(sizes[0]).toBe(15)
expect(sizes.every(s => s >= 1)).toBe(true)
})
})
describe('layoutGridAreas', () => {
it('solves a rowSpan cell so it occupies both rows and blocks placement beneath it', () => {
const layout = layoutGridAreas({
columns: 2,
gap: 0,
height: 10,
items: [{ id: 'tall', rowSpan: 2 }, { id: 'b' }, { id: 'c' }],
rowGap: 0,
width: 20
})
const [tall, b, c] = layout.cells
expect(tall).toMatchObject({ col: 0, height: 10, row: 0, rowSpan: 2, width: 10, x: 0, y: 0 })
expect(b).toMatchObject({ col: 1, row: 0, x: 10, y: 0 })
// c cannot go under `tall` — it lands in the second row's free column.
expect(c).toMatchObject({ col: 1, row: 1, x: 10, y: 5 })
})
it('fills holes densely (auto-flow dense) after a colSpan pushes a wrap', () => {
const layout = layoutGridAreas({
columns: 3,
gap: 0,
height: 6,
items: [{ id: 'a' }, { colSpan: 2, id: 'wide' }, { id: 'filler' }],
width: 30
})
const byId = Object.fromEntries(layout.cells.map(cell => [cell.id, cell]))
expect(byId['a']).toMatchObject({ col: 0, row: 0 })
expect(byId['wide']).toMatchObject({ col: 1, colSpan: 2, row: 0 })
expect(byId['filler']).toMatchObject({ col: 0, row: 1 })
})
it('spans include the gaps they bridge', () => {
const layout = layoutGridAreas({
columns: [{ fr: 1 }, { fr: 1 }, { fr: 1 }],
gap: 2,
height: 11,
items: [{ colSpan: 3, id: 'full' }, { id: 'a' }, { id: 'b' }, { rowSpan: 2, id: 'tall' }],
rowGap: 1,
width: 32
})
const byId = Object.fromEntries(layout.cells.map(cell => [cell.id, cell]))
// 3 tracks over usable 28 → [10, 9, 9]; full spans all three + two gaps.
expect(layout.columnSizes).toEqual([10, 9, 9])
expect(byId['full']!.width).toBe(32)
// 3 rows over usable 9 → [3, 3, 3]; tall spans rows 2-3 + one rowGap.
expect(layout.rowSizes).toEqual([3, 3, 3])
expect(byId['tall']!.height).toBe(7)
expect(byId['tall']!.y).toBe(4)
})
it('honors explicit col/row pins and grows implicit rows beneath explicit tracks', () => {
const layout = layoutGridAreas({
columns: 2,
gap: 0,
height: 12,
items: [
{ col: 1, id: 'pinned', row: 2 },
{ id: 'auto-a' },
{ id: 'auto-b' }
],
rowGap: 0,
rows: 2,
width: 10
})
const byId = Object.fromEntries(layout.cells.map(cell => [cell.id, cell]))
// The pin forces a third row beyond the two explicit tracks.
expect(layout.rowCount).toBe(3)
expect(byId['pinned']).toMatchObject({ col: 1, row: 2 })
expect(byId['auto-a']).toMatchObject({ col: 0, row: 0 })
expect(byId['auto-b']).toMatchObject({ col: 1, row: 0 })
})
it('supports weighted and fixed row tracks', () => {
const layout = layoutGridAreas({
columns: 1,
gap: 0,
height: 20,
items: [{ id: 'header' }, { id: 'body' }, { id: 'footer' }],
rowGap: 0,
rows: [3, { fr: 1 }, 3],
width: 40
})
expect(layout.rowSizes).toEqual([3, 14, 3])
const byId = Object.fromEntries(layout.cells.map(cell => [cell.id, cell]))
expect(byId['header']!.height).toBe(3)
expect(byId['body']).toMatchObject({ height: 14, y: 3 })
expect(byId['footer']).toMatchObject({ height: 3, y: 17 })
})
it('clamps colSpan to the column count', () => {
const layout = layoutGridAreas({
columns: 2,
gap: 0,
height: 4,
items: [{ colSpan: 9, id: 'huge' }],
width: 10
})
expect(layout.cells[0]).toMatchObject({ col: 0, colSpan: 2, width: 10 })
})
})

View file

@ -0,0 +1,143 @@
import { PassThrough } from 'stream'
import { renderSync, Text } from '@hermes/ink'
import React, { useState } from 'react'
import { describe, expect, it } from 'vitest'
import { GRID_STREAM_COUNT, type GridTestState } from '../app/interfaces.js'
import { GridStreamsDemo, STREAM_DEFS } from '../components/gridStreamsDemo.js'
import { GridAreas, type GridAreaWidget, WidgetGrid, type WidgetGridWidget } from '../components/widgetGrid.js'
import { stripAnsi } from '../lib/text.js'
import { DEFAULT_THEME } from '../theme.js'
function StatefulCell({ label }: { label: string }) {
const [value] = useState(label)
return <Text>{value}</Text>
}
const renderToText = (node: React.ReactElement) => {
const stdout = new PassThrough()
const stdin = new PassThrough()
const stderr = new PassThrough()
let output = ''
Object.assign(stdout, { columns: 100, isTTY: false, rows: 24 })
Object.assign(stdin, { isTTY: false })
Object.assign(stderr, { isTTY: false })
stdout.on('data', chunk => {
output += chunk.toString()
})
const instance = renderSync(node, {
patchConsole: false,
stderr: stderr as NodeJS.WriteStream,
stdin: stdin as NodeJS.ReadStream,
stdout: stdout as NodeJS.WriteStream
})
instance.unmount()
instance.cleanup()
return stripAnsi(output)
}
const renderGrid = (widgets: WidgetGridWidget[]) =>
renderToText(<WidgetGrid cols={80} columns={2} gap={1} paddingX={0} widgets={widgets} />)
describe('WidgetGrid component composition', () => {
it('renders stateful direct children and nested grids inside cells', () => {
const output = renderGrid([
{
children: <StatefulCell label="stateful-c1" />,
id: 'stateful'
},
{
children: (
<WidgetGrid
cols={38}
columns={2}
gap={1}
paddingX={0}
widgets={[
{ children: <StatefulCell label="nested-c1" />, id: 'nested-c1' },
{ render: () => <StatefulCell label="nested-c2" />, id: 'nested-c2' }
]}
/>
),
id: 'nested-grid'
}
])
expect(output).toContain('stateful-c1')
expect(output).toContain('nested-c1')
expect(output).toContain('nested-c2')
})
})
describe('GridAreas component', () => {
it('renders a rowSpan cell alongside stacked cells at their solved rects', () => {
const widgets: GridAreaWidget[] = [
{ id: 'tall', render: cell => <Text>{`tall ${cell.width}x${cell.height}`}</Text>, rowSpan: 2 },
{ children: <Text>top-right</Text>, id: 'b' },
{ children: cell => <Text>{`bottom-right y${cell.y}`}</Text>, id: 'c' }
]
const output = renderToText(<GridAreas columns={2} gap={0} height={6} rowGap={0} widgets={widgets} width={40} />)
// tall spans both rows of the 6-row grid at 20 cells wide.
expect(output).toContain('tall 20x6')
expect(output).toContain('top-right')
expect(output).toContain('bottom-right y3')
})
it('gives fixed header/footer rows their size and the fr body the rest', () => {
const widgets: GridAreaWidget[] = [
{ children: cell => <Text>{`header h${cell.height}`}</Text>, id: 'header' },
{ children: cell => <Text>{`body h${cell.height}`}</Text>, id: 'body' },
{ children: cell => <Text>{`footer h${cell.height}`}</Text>, id: 'footer' }
]
const output = renderToText(
<GridAreas columns={1} gap={0} height={12} rowGap={0} rows={[1, { fr: 1 }, 1]} widgets={widgets} width={30} />
)
expect(output).toContain('header h1')
expect(output).toContain('body h10')
expect(output).toContain('footer h1')
})
})
describe('GridStreamsDemo', () => {
const streamsState: GridTestState = {
activeCol: 0,
activeRow: 0,
areas: false,
cols: 4,
gap: null,
nested: false,
paddingX: null,
rows: 3,
streamFocus: 1,
streamMain: 2,
streams: true,
zoomed: false
}
it('keeps the panel count in lockstep with the input handler focus wrap', () => {
expect(STREAM_DEFS.length).toBe(GRID_STREAM_COUNT)
})
it('renders every stream panel with the promoted panel in the header', () => {
const output = renderToText(<GridStreamsDemo cols={90} state={streamsState} t={DEFAULT_THEME} />)
expect(output).toContain('hermes mission control')
for (const def of STREAM_DEFS) {
expect(output).toContain(def.title)
}
// streamMain: 2 → the memory panel owns the promoted slot.
expect(output).toContain('main: memory')
})
})

View file

@ -1,3 +1,7 @@
import { execFile } from 'child_process'
import { forceRedraw, onTerminalBackground, onTerminalForeground } from '@hermes/ink'
import { STARTUP_IMAGE, STARTUP_QUERY } from '../config/env.js'
import { STREAM_BATCH_MS } from '../config/timing.js'
import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js'
@ -9,12 +13,14 @@ import type {
GatewaySkin,
SessionMostRecentResponse
} from '../gatewayTypes.js'
import { relativeLuminance } from '../lib/color.js'
import { isTodoDone } from '../lib/liveProgress.js'
import { openExternalUrl } from '../lib/openExternalUrl.js'
import { rpcErrorMessage } from '../lib/rpc.js'
import { topLevelSubagents } from '../lib/subagentTree.js'
import { formatAbandonedClarify, formatToolCall, stripAnsi } from '../lib/text.js'
import { fromSkin } from '../theme.js'
import { writeBootTheme } from '../lib/themeBoot.js'
import { defaultThemeForCurrentBackground, detectLightMode, fromSkin, type Theme } from '../theme.js'
import type { Msg, SubagentProgress, SubagentStatus } from '../types.js'
import { applyDelegationStatus, getDelegationState } from './delegationStore.js'
@ -29,18 +35,251 @@ const NO_PROVIDER_RE = /\bNo (?:LLM|inference) provider configured\b/i
const statusFromBusy = () => (getUiState().busy ? 'running…' : 'ready')
const applySkin = (s: GatewaySkin) =>
patchUiState({
theme: fromSkin(
s.colors ?? {},
s.branding ?? {},
s.banner_logo ?? '',
s.banner_hero ?? '',
s.tool_prefix ?? '',
s.help_header ?? ''
)
// The last gateway skin, kept so the theme can be re-derived when the OSC-11
// background answer arrives after (or without) gateway.ready.
let lastSkin: GatewaySkin | null = null
const themeForSkin = (s: GatewaySkin) => {
// Polarity overrides OVERLAY the base palette, they don't replace it: a skin
// can ship a fills-only `light_colors` (flip the dark navy menu/status fills
// to light on a light terminal) while its vivid foreground golds keep coming
// from `colors` and render raw through fromSkin's shim. A full paired block
// still works — it just overrides every key it lists.
const paired = detectLightMode() ? s.light_colors : s.dark_colors
const colors =
paired && Object.keys(paired).length ? { ...(s.colors ?? {}), ...paired } : (s.colors ?? {})
return fromSkin(colors, s.branding ?? {}, s.banner_logo ?? '', s.banner_hero ?? '', s.tool_prefix ?? '', s.help_header ?? '')
}
// Patch the live theme AND persist it for the next launch's first frame
// (flash-free boot — see lib/themeBoot.ts).
//
// The force-redraw is load-bearing: a theme swap recolors EVERYTHING, but the
// renderer's diff/blit cache treats layout-unchanged regions as reusable, so
// incremental repaints after a swap can tear — stale cells keep the previous
// palette (observed live: gold headers from the boot theme composited with
// slate chrome, dark status fills surviving on a light terminal, half-
// overwritten glyphs reading as "shadows"). One full clear+repaint after the
// new theme has rendered guarantees a coherent frame. Deferred ~2 frames so
// React + Ink flush the recolored tree first; skipping identical themes keeps
// the no-op resolution path (boot cache confirmed by detection) paint-free.
let lastCommittedTheme: Theme | null = null
const commitTheme = (theme: Theme) => {
// First commit compares against the SEED uiStore mounted with (boot cache
// or default), not null — otherwise a first resolve that differs from the
// boot-cached theme skips the anti-tearing repaint (the exact seed≠skin
// case: cold start defaults dark, then resolves to a light skin).
const prev = lastCommittedTheme ?? getUiState().theme
const changed = !themesEqual(prev, theme)
lastCommittedTheme = theme
patchUiState({ theme })
writeBootTheme(theme, process.env.HERMES_TUI_BACKGROUND)
if (changed) {
setTimeout(() => forceRedraw(process.stdout), 40).unref?.()
}
}
const themesEqual = (a: Theme, b: Theme) => {
if (a === b) {
return true
}
for (const key of Object.keys(a.color) as (keyof Theme['color'])[]) {
if (a.color[key] !== b.color[key]) {
return false
}
}
return a.brand.name === b.brand.name && a.brand.prompt === b.brand.prompt && a.bannerLogo === b.bannerLogo && a.bannerHero === b.bannerHero
}
const applySkin = (s: GatewaySkin) => {
lastSkin = s
commitTheme(themeForSkin(s))
}
/** Re-derive the theme from current detection signals (env overrides, cached
* OSC-11 answer) used by /theme, config sync, and the OSC listener. */
export function reapplyTheme(): void {
commitTheme(lastSkin ? themeForSkin(lastSkin) : defaultThemeForCurrentBackground())
}
/**
* Apply the persisted mode pin (`display.tui_theme`). 'light'/'dark' bridge
* to HERMES_TUI_THEME the priority-2 signal `detectLightMode` already
* honors (only an explicit HERMES_TUI_LIGHT env var outranks it); 'auto'
* clears the pin so the OSC-11 probe + env heuristics decide. The pin exists
* because the probe cannot always be trusted: xterm.js hosts report #000000
* regardless of the painted background when the editor theme leaves the
* terminal background unset.
*/
// True once CONFIG (via light/dark) owns the HERMES_TUI_THEME env pin, so an
// 'auto' hydrate knows not to clobber a user's shell-exported pin.
let configPinnedTheme = false
export function applyConfiguredTuiTheme(raw: unknown): void {
const mode = String(raw ?? '')
.trim()
.toLowerCase()
const current = process.env.HERMES_TUI_THEME ?? ''
if (mode === 'light' || mode === 'dark') {
// Record config ownership BEFORE the match short-circuit — otherwise a
// pin that already matches (e.g. env and config agree at boot) leaves
// configPinnedTheme false, and a later 'auto' would refuse to clear it.
configPinnedTheme = true
if (current === mode) {
return
}
process.env.HERMES_TUI_THEME = mode
} else {
// 'auto' clears only a pin CONFIG set — never a HERMES_TUI_THEME the user
// exported in their shell, which is an explicit override that outranks
// auto-detection (see detectLightMode's priority order).
if (!current || !configPinnedTheme) {
return
}
configPinnedTheme = false
delete process.env.HERMES_TUI_THEME
}
reapplyTheme()
}
let themeBackgroundSyncStarted = false
/**
* Re-derive the theme from the terminal's ACTUAL background color once the
* OSC-11 probe answers. The env heuristics `detectLightMode` runs at module
* load are blind in xterm.js hosts (VS Code / Cursor set no COLORFGBG), so a
* light editor terminal otherwise gets the dark fallback palette. The answer
* is cached into HERMES_TUI_BACKGROUND the slot `detectLightMode` already
* reads (and child processes inherit) then the current skin (or the
* skinless default) is re-applied against the corrected base. Explicit
* HERMES_TUI_LIGHT / HERMES_TUI_THEME overrides still win inside
* detectLightMode, so users can pin a mode regardless of the probe.
*/
/** Infer the terminal's polarity from its reported FOREGROUND (OSC 10).
* Transparent profiles lie about the background (unset default = pure
* black) but report the theme's real foreground a bright foreground
* means a dark theme and vice versa. Returns a representative background
* for the inferred pole, or undefined when the answer is unusable
* (mid-gray foregrounds are ambiguous; #000000/#ffffff can be unset
* defaults themselves, so only clearly-toned answers count). */
export function polarityBackgroundFromForeground(hex: string): string | undefined {
const luminance = relativeLuminance(hex)
if (luminance === null || hex === '#000000' || hex === '#ffffff') {
return undefined
}
if (luminance >= 0.45) {
return '#1e1e1e'
}
if (luminance <= 0.2) {
return '#ffffff'
}
return undefined
}
export function syncThemeToTerminalBackground(): void {
if (themeBackgroundSyncStarted) {
return
}
themeBackgroundSyncStarted = true
let resolved = false
onTerminalBackground(hex => {
// Exactly-#000000 is the "unset default" fingerprint, not a measurement:
// xterm.js reports it when the editor theme sets no terminal background
// (observed: pure black reported on a white Cursor terminal), and tmux
// answers OSC 11 with its own black fallback regardless of the outer
// terminal — and tmux also strips TERM_PROGRAM, so no host allow-list
// can catch it. Real dark themes report their actual surface (#1e1e1e,
// #282828, …). Distrusting pure black universally is safe: the OSC-10
// foreground below resolves the pole for transparent hosts, and a truly
// pure-black terminal lands on dark either way.
if (hex === '#000000') {
return
}
resolved = true
process.env.HERMES_TUI_BACKGROUND = hex
reapplyTheme()
})
// Foreground tiebreaker for the distrusted-background case. The two OSC
// replies arrive in the same startup batch; this listener only commits when
// the background didn't (first-writer-wins via `resolved`), and an explicit
// user pin still outranks it inside detectLightMode.
onTerminalForeground(hex => {
if (resolved || process.env.HERMES_TUI_THEME || process.env.HERMES_TUI_LIGHT) {
return
}
const inferred = polarityBackgroundFromForeground(hex)
if (!inferred) {
return
}
resolved = true
process.env.HERMES_TUI_BACKGROUND = inferred
reapplyTheme()
})
// Last-resort inference when the probe never answers (or answered with the
// untrusted default): on macOS, editor themes overwhelmingly track the
// system appearance, so `AppleInterfaceStyle` is a strong prior. Runs only
// when no explicit signal exists (env pins/COLORFGBG all beat the cache
// slot this writes), after giving the probe a beat to answer.
setTimeout(() => {
if (
resolved ||
process.platform !== 'darwin' ||
process.env.HERMES_TUI_BACKGROUND ||
process.env.HERMES_TUI_THEME ||
process.env.HERMES_TUI_LIGHT ||
process.env.COLORFGBG
) {
return
}
execFile('defaults', ['read', '-g', 'AppleInterfaceStyle'], (error, stdout) => {
if (resolved || process.env.HERMES_TUI_BACKGROUND || process.env.HERMES_TUI_THEME) {
return
}
// `defaults read` exits non-zero when the key is absent — which MEANS
// light mode; "Dark" means dark. Cache as an inferred background so
// every later signal (config pin, real OSC answer) still outranks it.
const dark = !error && stdout.trim() === 'Dark'
// Mark resolved so a LATE OSC-10 foreground reply (also an inference)
// can't re-flip this committed guess after the fact — visible churn.
// A real OSC-11 background answer still corrects it: that listener
// intentionally doesn't gate on `resolved` (a measurement outranks an
// inference).
resolved = true
process.env.HERMES_TUI_BACKGROUND = dark ? '#1e1e1e' : '#ffffff'
reapplyTheme()
})
}, 1500).unref?.()
}
const dropBgTask = (taskId: string) =>
patchUiState(state => {
const next = new Set(state.bgTasks)
@ -79,6 +318,8 @@ const normalizeSubagentStatus = (status: unknown, fallback: SubagentStatus): Sub
}
export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: GatewayEvent) => void {
syncThemeToTerminalBackground()
const { rpc } = ctx.gateway
const { STARTUP_RESUME_ID, newSession, recoverSidRef, resumeById, setCatalog } = ctx.session
const { bellOnComplete, stdout, sys } = ctx.system

View file

@ -283,6 +283,8 @@ export interface OverlayState {
billing: BillingOverlayState | null
clarify: ClarifyReq | null
confirm: ConfirmReq | null
dialog: DialogState | null
gridTest: GridTestState | null
journey: boolean
modelPicker: boolean | { refresh?: boolean }
pager: null | PagerState
@ -295,12 +297,41 @@ export interface OverlayState {
sudo: null | SudoReq
}
export interface DialogState {
body: string
hint?: string
title?: string
zone?: 'bottom' | 'bottom-left' | 'bottom-right' | 'center' | 'left' | 'right' | 'top' | 'top-left' | 'top-right'
}
export interface PagerState {
lines: string[]
offset: number
title?: string
}
/** Number of live panels in the /grid-test streams demo (focus wraps mod this). */
export const GRID_STREAM_COUNT = 6
export interface GridTestState {
activeCol: number
activeRow: number
/** Areas mode: fixed-height 2D grid with rowSpan/colSpan demo cells. */
areas: boolean
cols: number
gap: null | number
nested: boolean
paddingX: null | number
rows: number
/** Streams mode: live-updating panels tiled by GridAreas. */
streams: boolean
/** Streams mode: which panel h/l focus is on (0-based, wraps). */
streamFocus: number
/** Streams mode: which panel owns the promoted 2x2 slot. */
streamMain: number
zoomed: boolean
}
export interface TranscriptRow {
index: number
key: string

View file

@ -9,6 +9,8 @@ const buildOverlayState = (): OverlayState => ({
billing: null,
clarify: null,
confirm: null,
dialog: null,
gridTest: null,
journey: false,
modelPicker: false,
pager: null,
@ -31,6 +33,8 @@ export const $isBlocked = computed(
billing,
clarify,
confirm,
dialog,
gridTest,
journey,
modelPicker,
pager,
@ -48,6 +52,8 @@ export const $isBlocked = computed(
billing ||
clarify ||
confirm ||
dialog ||
gridTest ||
journey ||
modelPicker ||
pager ||
@ -82,6 +88,8 @@ export const resetFlowOverlays = () =>
...buildOverlayState(),
agents: $overlayState.get().agents,
agentsInitialHistoryIndex: $overlayState.get().agentsInitialHistoryIndex,
dialog: $overlayState.get().dialog,
gridTest: $overlayState.get().gridTest,
journey: $overlayState.get().journey,
modelPicker: $overlayState.get().modelPicker,
petPicker: $overlayState.get().petPicker,

View file

@ -107,7 +107,9 @@ export const coreCommands: SlashCommand[] = [
'/details <section> [hidden|collapsed|expanded|reset]',
'override one section (thinking/tools/subagents/activity)'
],
['/fortune [random|daily]', 'show a random or daily local fortune']
['/fortune [random|daily]', 'show a random or daily local fortune'],
['/grid-test [cols]x[rows]', 'open the interactive widget-grid demo'],
['/dialog-test [zone]', 'open a sample dialog overlay with a faked backdrop']
],
title: 'TUI'
},

View file

@ -1,7 +1,117 @@
import { terminalBackgroundHex } from '@hermes/ink'
import { formatBytes, performHeapDump } from '../../../lib/memory.js'
import { detectLightMode } from '../../../theme.js'
import type { DialogState } from '../../interfaces.js'
import { patchOverlayState } from '../../overlayStore.js'
import { getUiState } from '../../uiStore.js'
import type { SlashCommand } from '../types.js'
const GRID_TEST_USAGE = 'usage: /grid-test [cols]x[rows] · /grid-test [cols] [rows] · /grid-test streams'
const GRID_TEST_MAX_SIZE = 12
const DIALOG_TEST_ZONES = new Set<DialogState['zone']>([
'bottom',
'bottom-left',
'bottom-right',
'center',
'left',
'right',
'top',
'top-left',
'top-right'
])
const DIALOG_TEST_USAGE = `usage: /dialog-test [zone] zones: ${[...DIALOG_TEST_ZONES].join(', ')}`
const clampGridSize = (value: number, fallback: number) => {
if (!Number.isFinite(value)) {
return fallback
}
return Math.max(1, Math.min(GRID_TEST_MAX_SIZE, Math.trunc(value)))
}
const parseGridTestSize = (arg: string) => {
const trimmed = arg.trim()
if (!trimmed) {
return { cols: 4, rows: 3 }
}
const grid = trimmed.match(/^(\d+)\s*x\s*(\d+)$/i)
if (grid) {
return { cols: clampGridSize(Number(grid[1]), 4), rows: clampGridSize(Number(grid[2]), 3) }
}
const [cols, rows, ...rest] = trimmed.split(/\s+/)
if (rest.length || !cols || !rows || Number.isNaN(Number(cols)) || Number.isNaN(Number(rows))) {
return null
}
return { cols: clampGridSize(Number(cols), 4), rows: clampGridSize(Number(rows), 3) }
}
export const debugCommands: SlashCommand[] = [
{
help: 'open an interactive widget-grid demo overlay',
name: 'grid-test',
run: (arg, ctx) => {
const streams = arg.trim().toLowerCase() === 'streams'
const size = streams ? { cols: 4, rows: 3 } : parseGridTestSize(arg)
if (!size) {
return ctx.transcript.sys(GRID_TEST_USAGE)
}
patchOverlayState({
gridTest: {
activeCol: 0,
activeRow: 0,
areas: false,
cols: size.cols,
gap: null,
nested: false,
paddingX: null,
rows: size.rows,
streamFocus: 0,
streamMain: 0,
streams,
zoomed: false
}
})
}
},
{
help: 'open a sample dialog overlay with a faked backdrop',
name: 'dialog-test',
run: (arg, ctx) => {
const trimmed = arg.trim().toLowerCase()
const zone = (trimmed || 'center') as DialogState['zone']
if (!DIALOG_TEST_ZONES.has(zone)) {
return ctx.transcript.sys(DIALOG_TEST_USAGE)
}
patchOverlayState({
dialog: {
body: [
'This is a viewport-level overlay with a backdrop.',
'',
`Zone: ${zone}`,
'Try: /dialog-test top-right · bottom · left · ...'
].join('\n'),
hint: 'Esc/q/Enter close · Ctrl+C close',
title: 'Dialog primitive',
zone
}
})
}
},
{
help: 'write a V8 heap snapshot + memory diagnostics (see HERMES_HEAPDUMP_DIR)',
name: 'heapdump',
@ -25,6 +135,31 @@ export const debugCommands: SlashCommand[] = [
}
},
{
help: 'print live theme diagnostics (background probe, light mode, palette)',
name: 'theme-info',
run: (_arg, ctx) => {
const { theme } = getUiState()
ctx.transcript.panel('Theme', [
{
rows: [
['OSC-11 background', terminalBackgroundHex() ?? '(no reply)'],
['HERMES_TUI_BACKGROUND', process.env.HERMES_TUI_BACKGROUND ?? '(unset)'],
['HERMES_TUI_THEME', process.env.HERMES_TUI_THEME ?? '(unset)'],
['COLORFGBG', process.env.COLORFGBG ?? '(unset)'],
['TERM_PROGRAM', process.env.TERM_PROGRAM ?? '(unset)'],
['detected mode', detectLightMode() ? 'light' : 'dark'],
['text', theme.color.text],
['completionBg', theme.color.completionBg],
['selectionBg', theme.color.selectionBg],
['statusBg', theme.color.statusBg]
]
}
])
}
},
{
help: 'print live V8 heap + rss numbers',
name: 'mem',

View file

@ -15,6 +15,7 @@ import type {
import { formatVoiceRecordKey, parseVoiceRecordKey } from '../../../lib/platform.js'
import { fmtK } from '../../../lib/text.js'
import type { PanelSection } from '../../../types.js'
import { applyConfiguredTuiTheme } from '../../createGatewayEventHandler.js'
import { DEFAULT_INDICATOR_STYLE, INDICATOR_STYLES, type IndicatorStyle } from '../../interfaces.js'
import { patchOverlayState } from '../../overlayStore.js'
import { patchUiState } from '../../uiStore.js'
@ -414,6 +415,43 @@ export const sessionCommands: SlashCommand[] = [
}
},
{
help: 'pin light/dark mode or trust auto-detection (usage: /theme [auto|light|dark])',
name: 'theme',
usage: '/theme [auto|light|dark]',
run: (arg, ctx) => {
const value = arg.trim().toLowerCase()
if (!value) {
return ctx.gateway
.rpc<ConfigGetValueResponse>('config.get', { key: 'theme' })
.then(ctx.guarded<ConfigGetValueResponse>(r => ctx.transcript.sys(`theme: ${r.value || 'auto'}`)))
}
if (!['auto', 'light', 'dark'].includes(value)) {
return ctx.transcript.sys('usage: /theme [auto|light|dark]')
}
// Apply only after the write is confirmed (mirrors /indicator): a
// failed config.set must not leave the session showing a theme that
// reverts on restart. A few ms later than an optimistic flip, but the
// env/theme state and config.yaml never disagree.
ctx.gateway
.rpc<ConfigSetResponse>('config.set', { key: 'theme', value })
.then(
ctx.guarded<ConfigSetResponse>(r => {
if (r.value === undefined) {
return
}
applyConfiguredTuiTheme(value)
ctx.transcript.sys(`theme → ${value}`)
})
)
.catch(ctx.guardedErr)
}
},
{
help: 'switch theme skin (fires skin.changed)',
name: 'skin',

View file

@ -2,6 +2,7 @@ import { atom, computed } from 'nanostores'
import { MOUSE_TRACKING } from '../config/env.js'
import { ZERO } from '../domain/usage.js'
import { bootTheme } from '../lib/themeBoot.js'
import { DEFAULT_THEME } from '../theme.js'
import { DEFAULT_INDICATOR_STYLE, type UiState } from './interfaces.js'
@ -30,7 +31,9 @@ const buildUiState = (): UiState => ({
status: 'summoning hermes…',
statusBar: 'top',
streaming: true,
theme: DEFAULT_THEME,
// Last session's resolved theme paints frame one (flash-free boot, like
// the desktop's hermes-boot-* keys); DEFAULT_THEME only on first launch.
theme: bootTheme ?? DEFAULT_THEME,
usage: ZERO
})

View file

@ -7,6 +7,7 @@ import type { ConfigFullResponse, ConfigMtimeResponse, ReloadMcpResponse } from
import { DEFAULT_VOICE_RECORD_KEY, type ParsedVoiceRecordKey, parseVoiceRecordKey } from '../lib/platform.js'
import { asRpcResult } from '../lib/rpc.js'
import { applyConfiguredTuiTheme } from './createGatewayEventHandler.js'
import {
type BusyInputMode,
DEFAULT_INDICATOR_STYLE,
@ -205,6 +206,8 @@ export const applyDisplay = (
setBell(!!d.bell_on_complete)
applyConfiguredTuiTheme(d.tui_theme)
// Only push the voice record key when the RPC actually returned a
// config payload. ``quietRpc()`` collapses failures to ``null``; if we
// reset the cached shortcut on every null we would clobber a custom
@ -242,6 +245,7 @@ export function useConfigSync({
sid
}: UseConfigSyncOptions) {
const mtimeRef = useRef(0)
const mcpRevRef = useRef('')
useEffect(() => {
if (!sid) {
@ -255,6 +259,11 @@ export function useConfigSync({
setVoiceEnabled(process.env.HERMES_VOICE === '1')
quietRpc<ConfigMtimeResponse>(gw, 'config.get', { key: 'mtime' }).then(r => {
mtimeRef.current = Number(r?.mtime ?? 0)
// Seed the MCP revision baseline too: after a normal boot mtime is
// already non-zero, so the poller's baseline branch never runs, and an
// unset mcpRevRef would make the FIRST cosmetic write (mtime bump, same
// mcp_rev) look like an MCP change and fire a needless reload.mcp.
mcpRevRef.current = String(r?.mcp_rev ?? '')
})
void hydrateFullConfig(gw, setBellOnComplete, setVoiceRecordKey)
}, [gw, setBellOnComplete, setVoiceEnabled, setVoiceRecordKey, sid])
@ -267,10 +276,12 @@ export function useConfigSync({
const id = setInterval(() => {
quietRpc<ConfigMtimeResponse>(gw, 'config.get', { key: 'mtime' }).then(r => {
const next = Number(r?.mtime ?? 0)
const nextMcpRev = String(r?.mcp_rev ?? '')
if (!mtimeRef.current) {
if (next) {
mtimeRef.current = next
mcpRevRef.current = nextMcpRev
}
return
@ -282,9 +293,21 @@ export function useConfigSync({
mtimeRef.current = next
quietRpc<ReloadMcpResponse>(gw, 'reload.mcp', { session_id: sid, confirm: true }).then(
r => r && turnController.pushActivity('MCP reloaded after config change')
)
// Reload MCP only when the MCP-relevant config actually changed.
// Cosmetic writes (/skin, /statusbar, /theme) bump mtime constantly;
// reconnecting every MCP server for those costs seconds and made
// skin switching feel glacial. Older gateways don't send mcp_rev —
// fall back to reload-on-any-change there.
const mcpChanged = !nextMcpRev || nextMcpRev !== mcpRevRef.current
mcpRevRef.current = nextMcpRev
if (mcpChanged) {
quietRpc<ReloadMcpResponse>(gw, 'reload.mcp', { session_id: sid, confirm: true }).then(
r => r && turnController.pushActivity('MCP reloaded after config change')
)
}
void hydrateFullConfig(gw, setBellOnComplete, setVoiceRecordKey)
})
}, MTIME_POLL_MS)

View file

@ -16,12 +16,14 @@ import { computePrecisionWheelStep, initPrecisionWheel } from '../lib/precisionW
import { computeWheelStep, initWheelAccelForHost } from '../lib/wheelAccel.js'
import { getInputSelection } from './inputSelectionStore.js'
import type {
GatewayRpc,
InputHandlerActions,
InputHandlerContext,
InputHandlerResult,
OverlayState
import {
type GatewayRpc,
GRID_STREAM_COUNT,
type GridTestState,
type InputHandlerActions,
type InputHandlerContext,
type InputHandlerResult,
type OverlayState
} from './interfaces.js'
import { $isBlocked, $overlayState, patchOverlayState } from './overlayStore.js'
import { turnController } from './turnController.js'
@ -127,6 +129,23 @@ export function dismissSensitivePrompt(
}
}
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value))
const GRID_TEST_MAX_SIZE = 12
const cycleAutoNumber = (value: null | number, max: number) => {
if (value === null) {
return 0
}
return value >= max ? null : value + 1
}
const keepGridCursorInBounds = (grid: GridTestState): GridTestState => ({
...grid,
activeCol: clamp(grid.activeCol, 0, grid.cols - 1),
activeRow: clamp(grid.activeRow, 0, grid.rows - 1)
})
export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
const { actions, composer, gateway, terminal, voice, wheelStep } = ctx
const { actions: cActions, refs: cRefs, state: cState } = composer
@ -218,6 +237,14 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
if (overlay.journey) {
return patchOverlayState({ journey: false })
}
if (overlay.gridTest) {
return patchOverlayState({ gridTest: null })
}
if (overlay.dialog) {
return patchOverlayState({ dialog: null })
}
}
const cycleQueue = (dir: 1 | -1) => {
@ -401,6 +428,159 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
return
}
if (overlay.gridTest) {
const updateGrid = (fn: (grid: GridTestState) => GridTestState) =>
patchOverlayState(prev =>
prev.gridTest ? { ...prev, gridTest: keepGridCursorInBounds(fn(prev.gridTest)) } : prev
)
const openDemoDialog = () =>
patchOverlayState({
dialog: {
body: ['Dialog overlaid on top of /grid-test.', '', 'Backdrop dims the grid behind.'].join('\n'),
hint: 'Esc/q/Enter close',
title: 'Overlay primitive',
zone: 'center'
}
})
const resetGrid = () =>
updateGrid(grid => ({
...grid,
activeCol: 0,
activeRow: 0,
areas: false,
cols: 4,
gap: null,
nested: false,
paddingX: null,
rows: 3,
streamFocus: 0,
streamMain: 0,
streams: false,
zoomed: false
}))
if (isCtrl(key, ch, 'c')) {
return patchOverlayState({ gridTest: null })
}
// Streams mode swallows the grid-shape keys: focus cycles across the
// live panels and Enter promotes the focused one to the 2x2 slot.
if (overlay.gridTest.streams) {
if (key.escape || ch === 'q' || ch === 's') {
return updateGrid(grid => ({ ...grid, streams: false }))
}
if (key.return) {
return updateGrid(grid => ({ ...grid, streamMain: grid.streamFocus }))
}
if (ch === 'd') {
return openDemoDialog()
}
if (ch === 'r') {
return resetGrid()
}
if (key.leftArrow || key.upArrow || ch === 'h' || ch === 'k') {
return updateGrid(grid => ({
...grid,
streamFocus: (grid.streamFocus + GRID_STREAM_COUNT - 1) % GRID_STREAM_COUNT
}))
}
if (key.rightArrow || key.downArrow || ch === 'l' || ch === 'j') {
return updateGrid(grid => ({ ...grid, streamFocus: (grid.streamFocus + 1) % GRID_STREAM_COUNT }))
}
return
}
if (overlay.gridTest.zoomed && (key.escape || ch === 'q')) {
return updateGrid(grid => ({ ...grid, zoomed: false }))
}
if (key.escape || ch === 'q') {
return patchOverlayState({ gridTest: null })
}
if (key.return) {
return updateGrid(grid => ({ ...grid, nested: true, zoomed: true }))
}
if (ch === 'n') {
return updateGrid(grid => ({ ...grid, nested: !grid.nested }))
}
if (ch === 'a') {
return updateGrid(grid => ({ ...grid, areas: !grid.areas, streams: false }))
}
if (ch === 's') {
return updateGrid(grid => ({ ...grid, areas: false, streams: true }))
}
if (ch === 'g') {
return updateGrid(grid => ({ ...grid, gap: cycleAutoNumber(grid.gap, 3) }))
}
if (ch === 'p') {
return updateGrid(grid => ({ ...grid, paddingX: cycleAutoNumber(grid.paddingX, 2) }))
}
if (ch === 'd') {
return openDemoDialog()
}
if (ch === 'r') {
return resetGrid()
}
if (ch === '+' || ch === '=') {
return updateGrid(grid => ({ ...grid, cols: clamp(grid.cols + 1, 1, GRID_TEST_MAX_SIZE) }))
}
if (ch === '-' || ch === '_') {
return updateGrid(grid => ({ ...grid, cols: clamp(grid.cols - 1, 1, GRID_TEST_MAX_SIZE) }))
}
if (ch === ']') {
return updateGrid(grid => ({ ...grid, rows: clamp(grid.rows + 1, 1, GRID_TEST_MAX_SIZE) }))
}
if (ch === '[') {
return updateGrid(grid => ({ ...grid, rows: clamp(grid.rows - 1, 1, GRID_TEST_MAX_SIZE) }))
}
if (key.leftArrow || ch === 'h') {
return updateGrid(grid => ({ ...grid, activeCol: clamp(grid.activeCol - 1, 0, grid.cols - 1) }))
}
if (key.rightArrow || ch === 'l') {
return updateGrid(grid => ({ ...grid, activeCol: clamp(grid.activeCol + 1, 0, grid.cols - 1) }))
}
if (key.upArrow || ch === 'k') {
return updateGrid(grid => ({ ...grid, activeRow: clamp(grid.activeRow - 1, 0, grid.rows - 1) }))
}
if (key.downArrow || ch === 'j') {
return updateGrid(grid => ({ ...grid, activeRow: clamp(grid.activeRow + 1, 0, grid.rows - 1) }))
}
return
}
if (overlay.dialog) {
if (key.escape || isCtrl(key, ch, 'c') || ch === 'q' || key.return) {
return patchOverlayState({ dialog: null })
}
return
}
if (isCtrl(key, ch, 'c') || (key.escape && (overlay.secret || overlay.sudo))) {
cancelOverlayFromCtrlC()
} else if (key.escape && overlay.sessions) {

View file

@ -16,6 +16,7 @@ import type { Theme } from '../theme.js'
import { ModelPicker } from './modelPicker.js'
import { windowOffset } from './overlayControls.js'
import { clampOverlayWidth, listRowStyle } from './overlayPrimitives.js'
import { TextInput } from './textInput.js'
const VISIBLE = 12
@ -153,9 +154,12 @@ export const orchestratorHintSegmentColor = (t: Theme, role: OrchestratorHintRol
return t.color.muted
}
// Delegates to the shared list-row primitive so the session switcher and the
// completions popover cannot disagree about what "selected" looks like.
// (`selectionBg` remains the TEXT-selection highlight — a different semantic.)
export const selectedSessionRowStyle = (t: Theme) => ({
backgroundColor: t.color.selectionBg,
color: t.color.text
backgroundColor: listRowStyle(t, true).backgroundColor,
color: listRowStyle(t, true).color
})
export const newSessionMarkerColor = (t: Theme, selected: boolean) =>
@ -283,6 +287,7 @@ function OrchestratorHintText({ segments, t }: OrchestratorHintTextProps) {
export function ActiveSessionSwitcher({
currentSessionId,
gw,
maxWidth,
onCancel,
onClose,
onNew,
@ -318,7 +323,9 @@ export function ActiveSessionSwitcher({
const itemsRef = useRef<SessionActiveItem[]>([])
const historyDisplayRef = useRef<SessionListItem[]>([])
const { stdout } = useStdout()
const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6))
// Optional maxWidth lets grid layouts hand the switcher its cell budget.
const preferredWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6))
const width = clampOverlayWidth(preferredWidth, maxWidth)
const promptColumns = Math.max(20, width - 11)
// Rows are [new][live…][history…]: the "+ new" row is pinned first (index 0,
@ -893,6 +900,7 @@ interface OrchestratorHintTextProps {
interface ActiveSessionSwitcherProps {
currentSessionId: null | string
gw: GatewayClient
maxWidth?: number
onCancel: () => void
onClose: (id: string) => Promise<null | SessionCloseResponse>
onNew: () => void

View file

@ -32,6 +32,7 @@ import { compactPreview } from '../lib/text.js'
import type { Theme } from '../theme.js'
import type { SubagentNode, SubagentProgress } from '../types.js'
import { listRowStyle } from './overlayPrimitives.js'
import { OverlayScrollbar } from './overlayScrollbar.js'
// ── Types + lookup tables ────────────────────────────────────────────
@ -464,14 +465,17 @@ function ListRow({
const paren = line ? line.indexOf('(') : -1
const toolShort = line ? (paren > 0 ? line.slice(0, paren) : line).trim() : ''
const trailing = toolShort ? ` · ${compactPreview(toolShort, 14)}` : ''
const fg = active ? t.color.accent : t.color.text
// Selection chip, not `inverse` — inverse swaps against the terminal's
// unknowable defaults (black slab on transparent profiles).
const row = listRowStyle(t, active)
const fg = active ? (row.color ?? t.color.accent) : t.color.text
return (
<Text bold={active} color={fg} inverse={active} wrap="truncate-end">
<Text backgroundColor={row.backgroundColor} bold={active} color={fg} wrap="truncate-end">
{' '}
<Text color={active ? fg : t.color.muted}>{formatRowId(index)} </Text>
{indentFor(node.item.depth)}
{heatMarker ? <Text color={heatMarker}></Text> : null}
{heatMarker ? <Text color={active ? fg : heatMarker}></Text> : null}
<Text color={active ? fg : color}>{glyph}</Text> {goal}
<Text color={active ? fg : t.color.muted}>
{toolsCount}

View file

@ -17,6 +17,8 @@ import { useScrollbarSnapshot, useViewportSnapshot } from '../lib/viewportStore.
import type { Theme } from '../theme.js'
import type { Msg, Usage } from '../types.js'
import { scrollbarColors } from './overlayPrimitives.js'
const FACE_TICK_MS = 2500
const HEART_COLORS = ['#ff5fa2', '#ff4d6d']
@ -753,8 +755,7 @@ export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps)
const thumb = scrollable ? Math.max(1, Math.round((vp * vp) / total)) : vp
const travel = Math.max(1, vp - thumb)
const thumbTop = scrollable ? Math.round((pos / Math.max(1, total - vp)) * travel) : 0
const thumbColor = grab !== null ? t.color.primary : hover ? t.color.accent : t.color.border
const trackColor = hover ? t.color.border : t.color.muted
const { thumb: thumbColor, track: trackColor } = scrollbarColors(t, hover, grab !== null)
const jump = (row: number, offset: number) => {
if (!s || !scrollable) {
@ -786,24 +787,19 @@ export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps)
}}
width={1}
>
{!scrollable ? (
<Text color={trackColor} dim>
{' \n'.repeat(Math.max(0, vp - 1))}{' '}
</Text>
) : (
{/* Nothing to scroll draw nothing (the width={1} Box still reserves
the column). Drawn-blank cells composite to a black bar on
transparent terminals same class as the removed opaque fills. */}
{!scrollable ? null : (
<>
{thumbTop > 0 ? (
<Text color={trackColor} dim={!hover}>
{`${'│\n'.repeat(Math.max(0, thumbTop - 1))}${thumbTop > 0 ? '│' : ''}`}
</Text>
<Text color={trackColor}>{`${'│\n'.repeat(Math.max(0, thumbTop - 1))}${thumbTop > 0 ? '│' : ''}`}</Text>
) : null}
{thumb > 0 ? (
<Text color={thumbColor}>{`${'┃\n'.repeat(Math.max(0, thumb - 1))}${thumb > 0 ? '┃' : ''}`}</Text>
) : null}
{vp - thumbTop - thumb > 0 ? (
<Text color={trackColor} dim={!hover}>
{`${'│\n'.repeat(Math.max(0, vp - thumbTop - thumb - 1))}${vp - thumbTop - thumb > 0 ? '│' : ''}`}
</Text>
<Text color={trackColor}>{`${'│\n'.repeat(Math.max(0, vp - thumbTop - thumb - 1))}${vp - thumbTop - thumb > 0 ? '│' : ''}`}</Text>
) : null}
</>
)}

View file

@ -28,6 +28,7 @@ import { FpsOverlay } from './fpsOverlay.js'
import { HelpHint } from './helpHint.js'
import { Journey } from './journey.js'
import { MessageLine } from './messageLine.js'
import { Dialog, Overlay } from './overlay.js'
import { PetKitty, PetSprite } from './petSprite.js'
import { QueuedMessages } from './queuedMessages.js'
import { LiveTodoPanel, StreamingAssistant } from './streamingAssistant.js'
@ -417,6 +418,12 @@ const ComposerPane = memo(function ComposerPane({
onPaste={composer.handleTextPaste}
onSubmit={composer.submit}
placeholder={composer.empty ? PLACEHOLDER : ui.busy ? 'Ctrl+C to interrupt…' : ''}
// Exactly the "(and N more toolsets…)" tone. `muted` is a
// MID-luminance family tone, so it reads receded on both
// poles even when polarity detection is wrong (transparent
// terminals lie about their background); anything blended
// toward the resolved surface inherits that wrong polarity.
placeholderColor={ui.theme.color.muted}
value={composer.input}
voiceRecordKey={composer.voiceRecordKey}
/>
@ -560,6 +567,20 @@ export const AppLayout = memo(function AppLayout({
{!overlay.agents && <PetPane />}
</Box>
{overlay.dialog && (
<Overlay backdrop zone={overlay.dialog.zone ?? 'center'}>
<Dialog
hint={overlay.dialog.hint ?? 'Esc/q close'}
title={overlay.dialog.title}
width={Math.min(60, composer.cols - 8)}
>
{overlay.dialog.body.split('\n').map((line, i) => (
<Text key={i}>{line || ' '}</Text>
))}
</Dialog>
</Overlay>
)}
</Shell>
)
})

View file

@ -1,5 +1,6 @@
import { Box, Text } from '@hermes/ink'
import { Box, stringWidth, Text } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import type { ReactNode } from 'react'
import { useGateway } from '../app/gatewayContext.js'
import type { AppOverlaysProps } from '../app/interfaces.js'
@ -9,17 +10,52 @@ import { $uiSessionId, $uiTheme } from '../app/uiStore.js'
import { ActiveSessionSwitcher } from './activeSessionSwitcher.js'
import { FloatBox } from './appChrome.js'
import { BillingOverlay } from './billingOverlay.js'
import { GridTestOverlay } from './gridTestOverlay.js'
import { MaskedPrompt } from './maskedPrompt.js'
import { ModelPicker } from './modelPicker.js'
import { OverlayHint } from './overlayControls.js'
import { listRowStyle } from './overlayPrimitives.js'
import { PetPicker } from './petPicker.js'
import { PluginsHub } from './pluginsHub.js'
import { ApprovalPrompt, ClarifyPrompt, ConfirmPrompt } from './prompts.js'
import { SkillsHub } from './skillsHub.js'
import { SubscriptionOverlay } from './subscriptionOverlay.js'
import { WidgetGrid, type WidgetGridWidget } from './widgetGrid.js'
const COMPLETION_WINDOW = 16
/**
* A prompt hosted in a single-cell WidgetGrid with the classic 1-cell padding.
* The inner full-width column restores the horizontal stretch the old plain
* padded Box gave its child, so rendering is identical; routing through the
* grid makes the prompt zone a layout-engine surface like the desktop app's
* pane shell.
*/
function PromptCell({ children, cols, id }: { children: ReactNode; cols: number; id: string }) {
return (
<Box flexDirection="column" flexShrink={0}>
<WidgetGrid
cols={cols}
columns={1}
gap={0}
paddingX={1}
paddingY={1}
rowGap={0}
widgets={[
{
children: (
<Box flexDirection="column" width="100%">
{children}
</Box>
),
id
}
]}
/>
</Box>
)
}
export function PromptZone({
cols,
onApprovalChoice,
@ -32,9 +68,9 @@ export function PromptZone({
if (overlay.approval) {
return (
<Box flexDirection="column" flexShrink={0} paddingX={1} paddingY={1}>
<PromptCell cols={cols} id="approval">
<ApprovalPrompt cols={cols} onChoice={onApprovalChoice} req={overlay.approval} t={theme} />
</Box>
</PromptCell>
)
}
@ -47,9 +83,9 @@ export function PromptZone({
const onClose = () => patchOverlayState({ billing: null })
return (
<Box flexDirection="column" flexShrink={0} paddingX={1} paddingY={1}>
<PromptCell cols={cols} id="billing">
<BillingOverlay onClose={onClose} onPatch={onPatch} overlay={current} t={theme} />
</Box>
</PromptCell>
)
}
@ -64,9 +100,9 @@ export function PromptZone({
const onClose = () => patchOverlayState({ subscription: null })
return (
<Box flexDirection="column" flexShrink={0} paddingX={1} paddingY={1}>
<PromptCell cols={cols} id="subscription">
<SubscriptionOverlay onClose={onClose} onPatch={onPatch} overlay={current} t={theme} />
</Box>
</PromptCell>
)
}
@ -81,15 +117,15 @@ export function PromptZone({
const onCancel = () => patchOverlayState({ confirm: null })
return (
<Box flexDirection="column" flexShrink={0} paddingX={1} paddingY={1}>
<PromptCell cols={cols} id="confirm">
<ConfirmPrompt onCancel={onCancel} onConfirm={onConfirm} req={req} t={theme} />
</Box>
</PromptCell>
)
}
if (overlay.clarify) {
return (
<Box flexDirection="column" flexShrink={0} paddingX={1} paddingY={1}>
<PromptCell cols={cols} id="clarify">
<ClarifyPrompt
cols={cols}
onAnswer={onClarifyAnswer}
@ -97,21 +133,21 @@ export function PromptZone({
req={overlay.clarify}
t={theme}
/>
</Box>
</PromptCell>
)
}
if (overlay.sudo) {
return (
<Box flexDirection="column" flexShrink={0} paddingX={1} paddingY={1}>
<PromptCell cols={cols} id="sudo">
<MaskedPrompt cols={cols} icon="🔐" label="sudo password required" onSubmit={onSudoSubmit} t={theme} />
</Box>
</PromptCell>
)
}
if (overlay.secret) {
return (
<Box flexDirection="column" flexShrink={0} paddingX={1} paddingY={1}>
<PromptCell cols={cols} id="secret">
<MaskedPrompt
cols={cols}
icon="🔑"
@ -120,7 +156,7 @@ export function PromptZone({
sub={`for ${overlay.secret.envVar}`}
t={theme}
/>
</Box>
</PromptCell>
)
}
@ -157,6 +193,7 @@ export function FloatingOverlays({
const theme = useStore($uiTheme)
const hasAny =
overlay.gridTest ||
overlay.modelPicker ||
overlay.pager ||
overlay.petPicker ||
@ -176,13 +213,38 @@ export function FloatingOverlays({
const start = Math.max(0, Math.min(compIdx - Math.floor(COMPLETION_WINDOW / 2), completions.length - viewportSize))
return (
<Box alignItems="flex-start" bottom="100%" flexDirection="column" left={0} position="absolute" right={0}>
{overlay.sessions && (
// Every floating panel is a widget in a single-column grid. Panels keep
// their intrinsic (content-hugging) widths inside full-width cells today;
// multi-column tiling on wide terminals is a `columns`/track change here,
// not a rewrite. `maxWidth` hands each panel its cell budget — with one
// column it never binds, so rendering is identical to the pre-grid layout.
const widgets: WidgetGridWidget[] = []
const gridTest = overlay.gridTest
if (gridTest) {
widgets.push({
id: 'grid-test',
render: () => (
<FloatBox color={theme.color.border}>
{/* cols-6 = FloatBox chrome (4) + margin (2); no 24-col floor
forcing one would overflow cells narrower than 28 and clip at
the terminal edge. */}
<GridTestOverlay cols={Math.max(1, cols - 6)} state={gridTest} t={theme} />
</FloatBox>
)
})
}
if (overlay.sessions) {
widgets.push({
id: 'sessions',
render: width => (
<FloatBox color={theme.color.border}>
<ActiveSessionSwitcher
currentSessionId={sid}
gw={gw}
maxWidth={width}
onCancel={() => patchOverlayState({ sessions: false })}
onClose={onActiveSessionClose}
onNew={onNewLiveSession}
@ -192,102 +254,156 @@ export function FloatingOverlays({
t={theme}
/>
</FloatBox>
)}
)
})
}
{overlay.modelPicker && (
if (overlay.modelPicker) {
const initialRefresh = typeof overlay.modelPicker === 'object' && overlay.modelPicker.refresh === true
widgets.push({
id: 'model-picker',
render: width => (
<FloatBox color={theme.color.border}>
<ModelPicker
gw={gw}
initialRefresh={typeof overlay.modelPicker === 'object' && overlay.modelPicker.refresh === true}
initialRefresh={initialRefresh}
maxWidth={width}
onCancel={() => patchOverlayState({ modelPicker: false })}
onSelect={onModelSelect}
sessionId={sid}
t={theme}
/>
</FloatBox>
)}
)
})
}
{overlay.petPicker && (
if (overlay.petPicker) {
widgets.push({
id: 'pet-picker',
render: width => (
<FloatBox color={theme.color.border}>
<PetPicker gw={gw} onClose={() => patchOverlayState({ petPicker: false })} t={theme} />
<PetPicker gw={gw} maxWidth={width} onClose={() => patchOverlayState({ petPicker: false })} t={theme} />
</FloatBox>
)}
)
})
}
{overlay.skillsHub && (
if (overlay.skillsHub) {
widgets.push({
id: 'skills-hub',
render: width => (
<FloatBox color={theme.color.border}>
<SkillsHub gw={gw} onClose={() => patchOverlayState({ skillsHub: false })} t={theme} />
<SkillsHub gw={gw} maxWidth={width} onClose={() => patchOverlayState({ skillsHub: false })} t={theme} />
</FloatBox>
)}
)
})
}
{overlay.pluginsHub && (
if (overlay.pluginsHub) {
widgets.push({
id: 'plugins-hub',
render: width => (
<FloatBox color={theme.color.border}>
<PluginsHub gw={gw} onClose={() => patchOverlayState({ pluginsHub: false })} t={theme} />
<PluginsHub gw={gw} maxWidth={width} onClose={() => patchOverlayState({ pluginsHub: false })} t={theme} />
</FloatBox>
)}
)
})
}
{overlay.pager && (
const pager = overlay.pager
if (pager) {
widgets.push({
id: 'pager',
render: () => (
<FloatBox color={theme.color.border}>
<Box flexDirection="column" paddingX={1} paddingY={1}>
{overlay.pager.title && (
{pager.title && (
<Box justifyContent="center" marginBottom={1}>
<Text bold color={theme.color.primary}>
{overlay.pager.title}
{pager.title}
</Text>
</Box>
)}
{overlay.pager.lines.slice(overlay.pager.offset, overlay.pager.offset + pagerPageSize).map((line, i) => (
{pager.lines.slice(pager.offset, pager.offset + pagerPageSize).map((line, i) => (
<Text key={i}>{line}</Text>
))}
<Box marginTop={1}>
<OverlayHint t={theme}>
{overlay.pager.offset + pagerPageSize < overlay.pager.lines.length
? `↑↓/jk line · Enter/Space/PgDn page · b/PgUp back · g/G top/bottom · Esc/q close (${Math.min(overlay.pager.offset + pagerPageSize, overlay.pager.lines.length)}/${overlay.pager.lines.length})`
: `end · ↑↓/jk · b/PgUp back · g top · Esc/q close (${overlay.pager.lines.length} lines)`}
{pager.offset + pagerPageSize < pager.lines.length
? `↑↓/jk line · Enter/Space/PgDn page · b/PgUp back · g/G top/bottom · Esc/q close (${Math.min(pager.offset + pagerPageSize, pager.lines.length)}/${pager.lines.length})`
: `end · ↑↓/jk · b/PgUp back · g top · Esc/q close (${pager.lines.length} lines)`}
</OverlayHint>
</Box>
</Box>
</FloatBox>
)}
)
})
}
{!!completions.length && (
if (completions.length) {
widgets.push({
id: 'completions',
render: () => (
<FloatBox color={theme.color.primary}>
{/* No painted panel fill: FloatBox is `opaque`, so rows sit on the
terminal's own background the one color that is always right
on a canvas we don't own (a full completionBg fill was the lone
surface painting its own background, which is why it could
disagree with every other overlay). Only the ACTIVE row carries
a selection chip, mirroring the session switcher. */}
<Box flexDirection="column" width={Math.max(28, cols - 6)}>
{completions.slice(start, start + viewportSize).map((item, i) => {
const active = start + i === compIdx
{(() => {
const visible = completions.slice(start, start + viewportSize)
// Two-column grid: the name track auto-sizes to the widest
// visible command, so descriptions align — and wrapped
// description lines stay inside their own column instead of
// running under the names.
const nameW = Math.max(...visible.map(item => stringWidth(item.display))) + 2
return (
<Box
backgroundColor={active ? theme.color.completionCurrentBg : theme.color.completionBg}
flexDirection="row"
key={`${start + i}:${item.text}:${item.display}:${item.meta ?? ''}`}
width="100%"
>
{/* flexShrink=0 when meta overflows the row, Ink/Yoga
otherwise shaves the last char off the display column
(e.g. /goal renders as /goa). */}
<Box flexShrink={0}>
<Text bold color={theme.color.label}>
{' '}
{item.display}
</Text>
return visible.map((item, i) => {
const active = start + i === compIdx
const row = listRowStyle(theme, active)
return (
<Box
backgroundColor={row.backgroundColor}
flexDirection="row"
key={`${start + i}:${item.text}:${item.display}:${item.meta ?? ''}`}
width="100%"
>
<Box flexShrink={0} width={nameW}>
<Text bold color={theme.color.label}>
{' '}
{item.display}
</Text>
</Box>
{item.meta ? (
// Descriptions in the neutral gray, NOT a gold-family
// tone — label vs muted are near-twins on some skins,
// which made command and description read as one run.
// Active row: meta rides the chip, so it uses row ink.
<Text backgroundColor={row.backgroundColor} color={active ? row.color : theme.color.statusFg}>
{item.meta}
</Text>
) : null}
</Box>
{item.meta ? (
<Text
backgroundColor={active ? theme.color.completionMetaCurrentBg : theme.color.completionMetaBg}
color={theme.color.muted}
>
{' '}
{item.meta}
</Text>
) : null}
</Box>
)
})}
)
})
})()}
</Box>
</FloatBox>
)}
)
})
}
return (
<Box alignItems="flex-start" bottom="100%" flexDirection="column" left={0} position="absolute" right={0}>
<WidgetGrid cols={cols} columns={1} gap={0} paddingX={0} paddingY={0} rowGap={0} widgets={widgets} />
</Box>
)
}

View file

@ -3,10 +3,14 @@ import { useEffect, useState } from 'react'
import unicodeSpinners from 'unicode-animations'
import { artWidth, caduceus, CADUCEUS_WIDTH, logo, LOGO_WIDTH } from '../banner.js'
import { mix } from '../lib/color.js'
import { flat } from '../lib/text.js'
import type { Theme } from '../theme.js'
import type { PanelSection, SessionInfo } from '../types.js'
import { ShimmerRows } from './loaders.js'
import { WidgetGrid } from './widgetGrid.js'
const LOADER_TICK_MS = 120
function InlineLoader({ label, t }: { label: string; t: Theme }) {
@ -28,8 +32,13 @@ function InlineLoader({ label, t }: { label: string; t: Theme }) {
}
export function ArtLines({ lines }: { lines: [string, string][] }) {
// No `opaque`: the banner is top-level content with nothing behind it, so
// it never needs the opaque space-fill (that's for absolute overlays). On a
// transparent terminal (terminal.background #00000000) the fill's "default
// background" spaces composite to black bars instead of the intended
// see-through — the reported ugly banner. Glyphs paint fine on their own.
return (
<Box flexDirection="column" height={lines.length} opaque width={artWidth(lines)}>
<Box flexDirection="column" height={lines.length} width={artWidth(lines)}>
{lines.map(([c, text], i) => (
<Text color={c} key={i} wrap="truncate-end">
{text}
@ -72,11 +81,18 @@ function CompactBanner({ cols, t }: { cols: number; t: Theme }) {
// -4 keeps a margin so exact-edge rows don't trip terminal pending-wrap.
const w = Math.max(28, cols - 4)
// No `opaque` (see ArtLines): the dashed rules are glyphs and the tagline's
// centering spaces carry the text's own fg style, so every cell paints with
// a real see-through background. The opaque fill was writing default-bg
// spaces that a transparent terminal renders as black bars.
// NOT bold: on Cursor's transparent-background terminal, a full-width run
// of BOLD box-drawing dashes renders with an opaque black cell background
// (the plain-dash rule right below renders clean — pixel-diffed live; the
// only stylistic delta was bold). Bold on short label runs is fine; bold on
// full-width box-drawing rows is what triggers the slab.
return (
<Box flexDirection="column" height={3} marginBottom={1} opaque width={w}>
<Text bold color={t.color.primary}>
{ruleIn(t.brand.name, w)}
</Text>
<Box flexDirection="column" height={3} marginBottom={1} width={w}>
<Text color={t.color.primary}>{ruleIn(t.brand.name, w)}</Text>
<Text color={t.color.muted}>{centerIn(TAG_FULL, w)}</Text>
<Text color={t.color.primary}>{'─'.repeat(w)}</Text>
</Box>
@ -94,19 +110,48 @@ export function Banner({ maxWidth, t }: { maxWidth?: number; t: Theme }) {
const logoLines = logo(t.color, t.bannerLogo || undefined)
const logoW = t.bannerLogo ? artWidth(logoLines) : LOGO_WIDTH
// Each tier renders its rows through a single-column WidgetGrid sized to
// the available columns — same visual output as the old plain flex column
// (cells clip where truncate-end used to), but the banner is now a
// layout-engine surface.
if (cols >= logoW + 2) {
return (
<Box flexDirection="column" marginBottom={1}>
<ArtLines lines={logoLines} />
<Text color={t.color.muted} wrap="truncate-end">
{t.brand.icon} {TAG_FULL}
</Text>
<WidgetGrid
cols={cols}
columns={1}
gap={0}
paddingX={0}
paddingY={0}
rowGap={0}
widgets={[
{ children: <ArtLines lines={logoLines} />, id: 'banner-art' },
{
children: (
<Text color={t.color.muted} wrap="truncate-end">
{t.brand.icon} {TAG_FULL}
</Text>
),
id: 'banner-tagline'
}
]}
/>
</Box>
)
}
if (cols >= COMPACT_FROM) {
return <CompactBanner cols={cols} t={t} />
return (
<WidgetGrid
cols={cols}
columns={1}
gap={0}
paddingX={0}
paddingY={0}
rowGap={0}
widgets={[{ children: <CompactBanner cols={cols} t={t} />, id: 'banner-compact' }]}
/>
)
}
const name = cols >= 52 ? t.brand.name : (t.brand.name.split(' ')[0] ?? t.brand.name)
@ -114,16 +159,50 @@ export function Banner({ maxWidth, t }: { maxWidth?: number; t: Theme }) {
return (
<Box flexDirection="column" marginBottom={1}>
<Text bold color={t.color.primary} wrap="truncate-end">
{t.brand.icon} {name}
</Text>
<Text color={t.color.muted} wrap="truncate-end">
{t.brand.icon} {tag}
</Text>
<WidgetGrid
cols={cols}
columns={1}
gap={0}
paddingX={0}
paddingY={0}
rowGap={0}
widgets={[
{
children: (
<Text bold color={t.color.primary} wrap="truncate-end">
{t.brand.icon} {name}
</Text>
),
id: 'banner-name'
},
{
children: (
<Text color={t.color.muted} wrap="truncate-end">
{t.brand.icon} {tag}
</Text>
),
id: 'banner-tag'
}
]}
/>
</Box>
)
}
// ── Skeleton ─────────────────────────────────────────────────────────
//
// Lazy sections render shimmer rows shaped like the real content (label
// block + value run) instead of a blank gap that pops when data lands.
// Row widths mirror the typical toolsets listing.
const SKELETON_ROWS: readonly (readonly [number, number])[] = [
[7, 30],
[7, 9],
[14, 12],
[12, 12],
[7, 7],
[10, 13]
]
// ── Collapsible helpers ──────────────────────────────────────────────
function CollapseToggle({
@ -168,6 +247,12 @@ export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) {
const lineBudget = Math.max(12, w - 2)
const strip = (s: string) => (s.endsWith('_tools') ? s.slice(0, -6) : s)
// Hierarchy: labels lead in the label tone; member lists recede in the
// muted/text midpoint. Anchoring on MUTED (mid-luminance by construction)
// keeps the fade readable on both poles even when polarity detection is
// wrong — surface-relative blends go invisible when text is already pale.
const listFade = mix(t.color.muted, t.color.text, 0.5)
// ── Local collapse state for each section ──
const [toolsOpen, setToolsOpen] = useState(true)
const [skillsOpen, setSkillsOpen] = useState(false)
@ -209,8 +294,8 @@ export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) {
<>
{shown.map(([k, vs]) => (
<Text key={k} wrap="truncate">
<Text color={t.color.muted}>{strip(k)}: </Text>
<Text color={t.color.text}>{truncLine(strip(k) + ': ', vs)}</Text>
<Text color={t.color.label}>{strip(k)}: </Text>
<Text color={listFade}>{truncLine(strip(k) + ': ', vs)}</Text>
</Text>
))}
{overflow > 0 && <Text color={t.color.muted}>(and {overflow} more categories)</Text>}
@ -229,6 +314,10 @@ export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) {
const mcpConnected = mcpServers.filter(s => s.connected).length
const toolsBody = () => {
if (info.lazy && toolEntries.length === 0) {
return <ShimmerRows color={listFade} highlight={t.color.label} rows={SKELETON_ROWS} />
}
const shown = toolEntries.slice(0, TOOLSETS_MAX)
const overflow = toolEntries.length - TOOLSETS_MAX
@ -236,8 +325,8 @@ export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) {
<>
{shown.map(([k, vs]) => (
<Text key={k} wrap="truncate">
<Text color={t.color.muted}>{strip(k)}: </Text>
<Text color={t.color.text}>{truncLine(strip(k) + ': ', vs)}</Text>
<Text color={t.color.label}>{strip(k)}: </Text>
<Text color={listFade}>{truncLine(strip(k) + ': ', vs)}</Text>
</Text>
))}
{overflow > 0 && <Text color={t.color.muted}>(and {overflow} more toolsets)</Text>}
@ -282,32 +371,36 @@ export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) {
return <Text color={t.color.muted}>{info.system_prompt}</Text>
}
return (
<Box borderColor={t.color.border} borderStyle="round" marginBottom={1} paddingX={2} paddingY={1}>
{wide && (
<Box flexDirection="column" marginRight={2} width={leftW}>
<ArtLines lines={heroLines} />
<Text />
// The wide layout is a real two-column grid: a fixed-width hero track and a
// flexible info track (grid-template-columns: <leftW> 1fr, gap 2) — the
// terminal equivalent of the desktop pane shell's fixed-vs-flex tracks.
// Narrow drops to a single flexible track. Track math reproduces the old
// hand-rolled widths exactly: usable = (leftW + 2 + w) - gap = leftW + w.
const heroColumn = wide ? (
<Box flexDirection="column" width="100%">
<ArtLines lines={heroLines} />
<Text />
<Text color={t.color.accent}>
{info.model.split('/').pop()}
<Text color={t.color.muted}> · Nous Research</Text>
</Text>
<Text color={t.color.accent}>
{info.model.split('/').pop()}
<Text color={t.color.muted}> · Nous Research</Text>
</Text>
<Text color={t.color.muted} wrap="truncate-end">
{info.cwd || process.cwd()}
</Text>
<Text color={t.color.muted} wrap="truncate-end">
{info.cwd || process.cwd()}
</Text>
{sid && (
<Text>
<Text color={t.color.sessionLabel}>Session: </Text>
<Text color={t.color.sessionBorder}>{sid}</Text>
</Text>
)}
</Box>
{sid && (
<Text>
<Text color={t.color.sessionLabel}>Session: </Text>
<Text color={t.color.sessionBorder}>{sid}</Text>
</Text>
)}
</Box>
) : null
<Box flexDirection="column" width={w}>
const infoColumn = (
<Box flexDirection="column" width="100%">
{wide ? (
<Box justifyContent="center" marginBottom={1}>
<Text bold color={t.color.primary}>
@ -389,8 +482,9 @@ export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) {
<Text />
<Text color={t.color.text}>
{toolsTotal} tools{' · '}
{skillsTotal} skills
{/* Lazy boot: never print "0 tools · 0 skills" while counts load. */}
{info.lazy && !toolsTotal ? '… ' : `${toolsTotal} `}tools{' · '}
{info.lazy && !skillsTotal ? '… ' : `${skillsTotal} `}skills
{mcpConnected ? ` · ${mcpConnected} MCP` : ''}
{' · '}
<Text color={t.color.muted}>/help for commands</Text>
@ -418,7 +512,27 @@ export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) {
! {info.install_warning}
</Text>
)}
</Box>
</Box>
)
return (
<Box borderColor={t.color.border} borderStyle="round" marginBottom={1} paddingX={2} paddingY={1}>
<WidgetGrid
cols={wide ? leftW + 2 + w : w}
columns={wide ? [leftW, { fr: 1 }] : 1}
gap={2}
paddingX={0}
paddingY={0}
rowGap={0}
widgets={
wide
? [
{ children: heroColumn, id: 'session-hero' },
{ children: infoColumn, id: 'session-info' }
]
: [{ children: infoColumn, id: 'session-info' }]
}
/>
</Box>
)
}

View file

@ -0,0 +1,393 @@
import { Box, Text } from '@hermes/ink'
import { memo, type ReactNode, useEffect, useRef, useState } from 'react'
import type { GridTestState } from '../app/interfaces.js'
import type { GridAreaCell, GridTrackSize } from '../lib/widgetGrid.js'
import type { Theme } from '../theme.js'
import { GridAreas, type GridAreaWidget } from './widgetGrid.js'
// The streams demo tiles six independently-ticking panels through
// `layoutGridAreas`: one panel owns a promoted 2x2 slot, the rest auto-place
// densely around it. Promoting a different panel reorders/respans the widget
// list — but cells are keyed by id, so every stream keeps its history while
// the grid reshapes around it. That relayout-without-reset is the point of
// the demo.
const HEADER_ROWS = 3
const STREAM_ROWS = 3
const GRID_HEIGHT = HEADER_ROWS + STREAM_ROWS * 6
const SPARK_CHARS = '▁▂▃▄▅▆▇█'
interface StreamDef {
id: string
render: (inner: { height: number; t: Theme; width: number }) => ReactNode
title: string
}
// ── tick + history hooks ────────────────────────────────────────────────────
const useTick = (ms: number) => {
const [tick, setTick] = useState(0)
useEffect(() => {
const timer = setInterval(() => setTick(v => v + 1), ms)
return () => clearInterval(timer)
}, [ms])
return tick
}
/** Ring-buffered sample history: one `sample()` per tick, capped at `cap`. */
const useHistory = (tick: number, sample: () => number, cap = 240) => {
const historyRef = useRef<number[]>([])
useEffect(() => {
historyRef.current.push(sample())
if (historyRef.current.length > cap) {
historyRef.current.splice(0, historyRef.current.length - cap)
}
// The sampler is intentionally re-run per tick, not per identity.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tick])
return historyRef.current
}
/**
* Render history as a column chart `rows` lines tall (top line first). Each
* column gets `rows * 8` vertical levels full blocks below the value, a
* partial eighth-block at the value row, spaces above so a promoted (taller)
* cell genuinely gains chart resolution rather than just whitespace.
*/
const sparkRows = (history: number[], width: number, rows: number): string[] => {
const window = history.slice(-Math.max(1, width))
if (!window.length) {
return Array.from({ length: rows }, () => '')
}
const min = Math.min(...window)
const max = Math.max(...window)
const range = max - min || 1
const levels = window.map(v => Math.max(1, Math.round(((v - min) / range) * rows * 8)))
return Array.from({ length: rows }, (_, lineIdx) => {
const rowFromBottom = rows - 1 - lineIdx
return levels
.map(level => {
const filled = Math.min(8, Math.max(0, level - rowFromBottom * 8))
return filled === 0 ? ' ' : SPARK_CHARS[filled - 1]
})
.join('')
})
}
// ── stream panels ───────────────────────────────────────────────────────────
const TOKEN_WORDS =
(`Hermes streams tokens into the promoted cell while the grid reshapes around it. ` +
`Cells are keyed by id, so promotion never resets a panel — history, cursors and ` +
`tickers all survive the relayout. Row and column tracks re-solve to integer ` +
`terminal cells on every change, spans bridge the gaps they cross, and dense ` +
`auto-placement backfills the holes the promoted panel leaves behind. `).split(' ')
function TokenStream({ height, t, width }: { height: number; t: Theme; width: number }) {
const tick = useTick(90)
const wordCount = tick % (TOKEN_WORDS.length * 4)
// Keep roughly enough trailing text to fill the cell, on word boundaries.
const budget = Math.max(16, width * height)
const words: string[] = []
let used = 0
for (let i = wordCount; i >= 0 && used < budget; i--) {
const word = TOKEN_WORDS[i % TOKEN_WORDS.length]!
words.unshift(word)
used += word.length + 1
}
return (
<Text color={t.color.text} wrap="wrap">
{words.join(' ')}
<Text color={t.color.primary}></Text>
</Text>
)
}
function SparkStream({
color,
format,
height,
interval,
sample,
t,
width
}: {
color: string
format: (value: number) => string
height: number
interval: number
sample: () => number
t: Theme
width: number
}) {
const tick = useTick(interval)
const history = useHistory(tick, sample)
const current = history[history.length - 1] ?? 0
// Chart height grows with the cell — a promoted cell gets a taller chart.
const rows = Math.max(1, height - 1)
return (
<Box flexDirection="column">
<Text color={t.color.muted} wrap="truncate">
{format(current)}
</Text>
{sparkRows(history, width, rows).map((line, idx) => (
<Text color={color} key={idx} wrap="truncate">
{line}
</Text>
))}
</Box>
)
}
const FAKE_TOOLS = [
'┊ read_file src/app/chat.tsx',
'┊ terminal npm run typecheck',
'┊ search_files "GridAreas"',
'┊ patch ui-tui/src/lib/widgetGrid.ts',
'┊ web_search yoga absolute layout',
'┊ delegate_task refactor sparkline',
'┊ terminal scripts/run_tests.sh',
'┊ vision_analyze screenshot.png'
]
function ToolTicker({ height, t }: { height: number; t: Theme; width: number }) {
const tick = useTick(600)
const visible = Math.max(1, height)
const lines = Array.from({ length: Math.min(visible, tick + 1) }, (_, idx) => {
const line = FAKE_TOOLS[(tick - idx) % FAKE_TOOLS.length]!
return { line, recent: idx === 0 }
}).reverse()
return (
<Box flexDirection="column">
{lines.map(({ line, recent }, idx) => (
<Text color={recent ? t.color.text : t.color.muted} key={idx} wrap="truncate">
{line}
</Text>
))}
</Box>
)
}
function MetaPanel({ t }: { height: number; t: Theme; width: number }) {
const tick = useTick(1000)
const startedRef = useRef(Date.now())
const uptime = Math.floor((Date.now() - startedRef.current) / 1000)
return (
<Box flexDirection="column">
<Text color={t.color.text}>{new Date().toLocaleTimeString()}</Text>
<Text color={t.color.muted}>{`up ${Math.floor(uptime / 60)}m ${uptime % 60}s`}</Text>
<Text color={t.color.muted}>{`${tick} ticks`}</Text>
</Box>
)
}
// ── stream registry ─────────────────────────────────────────────────────────
const sineSampler = () => (Math.sin(Date.now() / 700) + 1) / 2 + Math.random() * 0.15
let walkValue = 0.5
const walkSampler = () => {
walkValue = Math.min(1, Math.max(0, walkValue + (Math.random() - 0.5) * 0.2))
return walkValue
}
/** Exported so tests can assert the count matches GRID_STREAM_COUNT (focus wraps mod it). */
export const STREAM_DEFS: StreamDef[] = [
{
id: 'tokens',
render: ({ height, t, width }) => <TokenStream height={height} t={t} width={width} />,
title: 'token stream'
},
{
id: 'throughput',
render: ({ height, t, width }) => (
<SparkStream
color={t.color.accent}
format={v => `${Math.round(20 + v * 40)} tok/s`}
height={height}
interval={150}
sample={sineSampler}
t={t}
width={width}
/>
),
title: 'throughput'
},
{
id: 'heap',
render: ({ height, t, width }) => (
<SparkStream
color={t.color.ok}
format={v => `heap ${(v / 1024 / 1024).toFixed(1)} MB`}
height={height}
interval={500}
sample={() => process.memoryUsage().heapUsed}
t={t}
width={width}
/>
),
title: 'memory'
},
{
id: 'latency',
render: ({ height, t, width }) => (
<SparkStream
color={t.color.warn}
format={v => `${Math.round(20 + v * 180)} ms`}
height={height}
interval={250}
sample={walkSampler}
t={t}
width={width}
/>
),
title: 'latency'
},
{
id: 'tools',
render: ({ height, t, width }) => <ToolTicker height={height} t={t} width={width} />,
title: 'tool feed'
},
{
id: 'meta',
render: ({ height, t, width }) => <MetaPanel height={height} t={t} width={width} />,
title: 'session'
}
]
// ── the demo surface ────────────────────────────────────────────────────────
function StreamPanel({
cell,
focused,
main,
t,
title,
children
}: {
cell: GridAreaCell
children: (inner: { height: number; t: Theme; width: number }) => ReactNode
focused: boolean
main: boolean
t: Theme
title: string
}) {
const innerWidth = Math.max(1, cell.width - 4)
const innerHeight = Math.max(1, cell.height - 3)
const borderColor = focused ? t.color.primary : main ? t.color.accent : t.color.border
return (
<Box
borderColor={borderColor}
borderStyle="round"
flexDirection="column"
height={cell.height}
paddingX={1}
width={cell.width}
>
<Text bold={focused} color={focused ? t.color.primary : t.color.label} wrap="truncate">
{focused ? '▸ ' : ' '}
{title}
{main ? ' ·' : ''}
</Text>
<Box flexDirection="column" height={innerHeight} overflow="hidden" width={innerWidth}>
{children({ height: innerHeight, t, width: innerWidth })}
</Box>
</Box>
)
}
export const GridStreamsDemo = memo(function GridStreamsDemo({
cols,
state,
t
}: {
cols: number
state: GridTestState
t: Theme
}) {
const columnTracks: GridTrackSize[] = [{ fr: 1 }, { fr: 1 }, { fr: 1 }]
const main = STREAM_DEFS[state.streamMain % STREAM_DEFS.length]!
// Promoted panel first so dense placement gives it the top-left 2x2; the
// rest backfill in definition order. Ids never change, so React reconciles
// each panel across promotions and its ticking state survives.
const ordered = [main, ...STREAM_DEFS.filter(def => def.id !== main.id)]
const widgets: GridAreaWidget[] = [
{
colSpan: 3,
id: 'stream-header',
render: cell => (
<Box
alignItems="center"
borderColor={t.color.border}
borderStyle="round"
height={cell.height}
justifyContent="space-between"
paddingX={1}
width={cell.width}
>
<Text bold color={t.color.primary}>
hermes mission control
</Text>
<Text color={t.color.muted}>{`main: ${main.title}`}</Text>
</Box>
)
},
...ordered.map((def, idx) => ({
colSpan: idx === 0 ? 2 : 1,
id: `stream-${def.id}`,
render: (cell: GridAreaCell) => (
<StreamPanel
cell={cell}
focused={STREAM_DEFS[state.streamFocus % STREAM_DEFS.length]!.id === def.id}
main={def.id === main.id}
t={t}
title={def.title}
>
{def.render}
</StreamPanel>
),
rowSpan: idx === 0 ? 2 : 1
}))
]
return (
<GridAreas
columns={columnTracks}
gap={1}
height={GRID_HEIGHT}
rowGap={0}
rows={[HEADER_ROWS, { fr: 1 }, { fr: 1 }, { fr: 1 }]}
widgets={widgets}
width={cols}
/>
)
})

View file

@ -0,0 +1,316 @@
import { Box, Text } from '@hermes/ink'
import type { GridTestState } from '../app/interfaces.js'
import type { GridAreaCell, GridTrackSize } from '../lib/widgetGrid.js'
import type { Theme } from '../theme.js'
import { GridStreamsDemo } from './gridStreamsDemo.js'
import { GridAreas, type GridAreaWidget, WidgetGrid, type WidgetGridWidget } from './widgetGrid.js'
interface GridTestOverlayProps {
cols: number
state: GridTestState
t: Theme
}
const NESTED_GAP = 1
// Heights are odd so a single line is true-centered: border, blank, text, blank, border.
const FLAT_CELL_HEIGHT = 5
const NESTED_CELL_HEIGHT = 9
const MINI_CELL_HEIGHT = 3
// Sparse "every-other" pattern so the nested toggle visibly differs from the active cell.
const showsNestedPreview = (row: number, col: number) => row % 2 === 0 && col % 2 === 0
export function GridTestOverlay({ cols, state, t }: GridTestOverlayProps) {
const gridCols = Math.max(12, cols)
const activeIdx = state.activeRow * state.cols + state.activeCol
const activeLabel = `c${activeIdx + 1}`
const widgets: WidgetGridWidget[] = Array.from({ length: state.rows * state.cols }, (_, idx) => {
const row = Math.floor(idx / state.cols)
const col = idx % state.cols
const active = idx === activeIdx
const label = `c${idx + 1}`
return {
id: `cell-${idx}`,
render: width => (
<GridCell
active={active}
label={label}
nested={state.nested && showsNestedPreview(row, col)}
nestedMode={state.nested}
t={t}
width={width}
/>
)
}
})
return (
<Box flexDirection="column" paddingY={1} width={gridCols}>
<Box justifyContent="space-between" marginBottom={1} width="100%">
<Text bold color={t.color.primary}>
{state.zoomed ? `/grid-test / r${state.activeRow + 1} c${state.activeCol + 1}` : '/grid-test'}
</Text>
<Text color={t.color.muted}>{state.streams ? 'streams' : `${state.cols}x${state.rows} grid`}</Text>
</Box>
<Text color={t.color.muted} wrap="truncate">
{state.zoomed
? 'arrows/hjkl switch cell · Esc/q back · Ctrl+C close'
: state.streams
? 'arrows/hjkl focus · Enter promote · d dialog · Esc/q back · Ctrl+C close'
: 'arrows/hjkl move · Enter zoom · d dialog · a areas · s streams · +/- cols · [] rows · g gap · p pad · n nest · q close'}
</Text>
<Box marginTop={1}>
{state.zoomed ? (
<ZoomedGridCell cols={gridCols} parentLabel={activeLabel} t={t} />
) : state.streams ? (
<GridStreamsDemo cols={gridCols} state={state} t={t} />
) : state.areas ? (
<AreasDemo cols={gridCols} state={state} t={t} />
) : (
<WidgetGrid
cols={gridCols}
columns={state.cols}
gap={state.gap ?? (state.nested ? NESTED_GAP : undefined)}
minColumnWidth={1}
paddingX={state.paddingX ?? undefined}
rowGap={0}
widgets={widgets}
/>
)}
</Box>
{!state.zoomed && !state.streams && (
<Box marginTop={1}>
<Text color={t.color.muted} wrap="truncate">
gap {state.gap ?? 'auto'} · pad {state.paddingX ?? 'auto'} · nested {state.nested ? 'on' : 'off'} · areas{' '}
{state.areas ? 'on (2fr first col · c1 spans rows · c2 spans cols)' : 'off'}
</Text>
</Box>
)}
</Box>
)
}
/**
* Areas-mode demo: the same cols×rows surface driven by `GridAreas` a
* weighted first column (2fr vs 1fr when there's room), `c1` spanning two
* rows, `c2` spanning two columns, everything else auto-placed dense. The
* cursor highlights whichever cell's row/col range contains it, so moving
* onto a spanned cell lights the whole merged area.
*/
function AreasDemo({ cols, state, t }: { cols: number; state: GridTestState; t: Theme }) {
const height = state.rows * FLAT_CELL_HEIGHT
const columnTracks: GridTrackSize[] =
state.cols >= 3
? [{ fr: 2 }, ...Array.from({ length: state.cols - 1 }, () => ({ fr: 1 }))]
: Array.from({ length: state.cols }, () => ({ fr: 1 }))
const rowSpanFirst = state.rows >= 2 ? 2 : 1
const colSpanSecond = state.cols >= 2 ? 2 : 1
const extraSlots = (rowSpanFirst - 1) + (colSpanSecond - 1)
const itemCount = Math.max(1, state.rows * state.cols - extraSlots)
const cursorInside = (cell: GridAreaCell) =>
state.activeRow >= cell.row &&
state.activeRow < cell.row + cell.rowSpan &&
state.activeCol >= cell.col &&
state.activeCol < cell.col + cell.colSpan
const widgets: GridAreaWidget[] = Array.from({ length: itemCount }, (_, idx) => ({
colSpan: idx === 1 ? colSpanSecond : 1,
id: `area-c${idx + 1}`,
render: (cell: GridAreaCell) => <AreaDemoCell active={cursorInside(cell)} cell={cell} label={`c${idx + 1}`} t={t} />,
rowSpan: idx === 0 ? rowSpanFirst : 1
}))
return (
<GridAreas
columns={columnTracks}
gap={state.gap ?? 1}
height={height}
rowGap={0}
rows={state.rows}
widgets={widgets}
width={cols}
/>
)
}
function AreaDemoCell({ active, cell, label, t }: { active: boolean; cell: GridAreaCell; label: string; t: Theme }) {
const borderColor = active ? t.color.primary : t.color.border
const labelColor = active ? t.color.primary : t.color.label
return (
<Box
alignItems="center"
borderColor={borderColor}
borderStyle="round"
flexDirection="column"
height={cell.height}
justifyContent="center"
width={cell.width}
>
<Text bold={active} color={labelColor}>
{label}
</Text>
{cell.height >= 5 && (
<Text color={t.color.muted}>
{cell.colSpan > 1 || cell.rowSpan > 1 ? `${cell.colSpan}x${cell.rowSpan}` : `${cell.width}w`}
</Text>
)}
</Box>
)
}
function GridCell({
active,
label,
nested,
nestedMode,
t,
width
}: {
active: boolean
label: string
nested: boolean
nestedMode: boolean
t: Theme
width: number
}) {
const padX = width >= 14 ? 1 : 0
const inner = Math.max(1, width - 2 - padX * 2)
const borderColor = active ? t.color.primary : t.color.border
const height = nestedMode ? NESTED_CELL_HEIGHT : FLAT_CELL_HEIGHT
const labelColor = active ? t.color.primary : t.color.label
return (
<Box
borderColor={borderColor}
borderStyle="round"
flexDirection="column"
height={height}
paddingX={padX}
width={width}
>
{nested && width >= 10 ? (
<>
<Box justifyContent="center" width={inner}>
<Text bold={active} color={labelColor}>
{label}
</Text>
</Box>
<WidgetGrid
cols={inner}
columns={2}
depth={1}
gap={NESTED_GAP}
minColumnWidth={1}
paddingX={0}
rowGap={0}
widgets={childCellWidgets(t, 3, 2)}
/>
</>
) : (
<Box alignItems="center" flexGrow={1} justifyContent="center" width={inner}>
<Text bold={active} color={labelColor}>
{label}
</Text>
</Box>
)}
</Box>
)
}
function ZoomedGridCell({ cols, parentLabel, t }: { cols: number; parentLabel: string; t: Theme }) {
const childColumns = cols >= 72 ? 4 : 2
const contentWidth = Math.max(1, cols - 4)
return (
<Box
borderColor={t.color.primary}
borderStyle="round"
flexDirection="column"
paddingX={1}
paddingY={1}
width={cols}
>
<WidgetGrid
cols={contentWidth}
columns={2}
depth={1}
gap={1}
minColumnWidth={1}
paddingX={0}
rowGap={0}
widgets={[
{
children: (
<Text bold color={t.color.primary}>
parent {parentLabel}
</Text>
),
id: 'header-title'
},
{
children: (
<Box justifyContent="flex-end" width="100%">
<Text color={t.color.muted}>nested child grid</Text>
</Box>
),
id: 'header-meta'
}
]}
/>
<Box height={1} />
<WidgetGrid
cols={contentWidth}
columns={childColumns}
depth={1}
gap={NESTED_GAP}
minColumnWidth={1}
paddingX={0}
rowGap={0}
widgets={childCellWidgets(t, childColumns * 2, childColumns)}
/>
</Box>
)
}
const childCellWidgets = (t: Theme, count: number, columns: number): WidgetGridWidget[] => {
const colors = [t.color.ok, t.color.warn, t.color.accent, t.color.muted, t.color.primary]
// 3-cell preview: two side-by-side, third spans the full row.
const lastSpansRow = count === 3
return Array.from({ length: count }, (_, idx) => ({
colSpan: lastSpansRow && idx === count - 1 ? columns : 1,
id: `child-c${idx + 1}`,
render: w => <MiniCell color={colors[idx % colors.length]!} label={`c${idx + 1}`} width={w} />
}))
}
function MiniCell({ color, label, width }: { color: string; label: string; width: number }) {
return (
<Box
alignItems="center"
borderColor={color}
borderStyle="single"
height={MINI_CELL_HEIGHT}
justifyContent="center"
overflow="hidden"
width={width}
>
<Text color={color}>{label}</Text>
</Box>
)
}

View file

@ -7,6 +7,7 @@ import { rpcErrorMessage } from '../lib/rpc.js'
import { deriveStarmapPalette, fadeHex, fadeInk, type StarmapPalette } from '../lib/starmapPalette.js'
import type { Theme } from '../theme.js'
import { listRowStyle } from './overlayPrimitives.js'
import { OverlayScrollbar } from './overlayScrollbar.js'
interface MutationResult {
@ -115,12 +116,14 @@ function ChartRow({ palette, row }: { palette: StarmapPalette; row: Run[] }) {
}
// Full-width selectable row, matching the /agents list treatment: the active
// row inverts and collapses every segment onto the accent foreground.
// row carries the shared selection chip (never `inverse`, which swaps against
// the terminal's unknowable defaults) and collapses onto the chip ink.
function ListRow({ active, cells, t }: { active: boolean; cells: Cell[]; t: Theme }) {
const fg = active ? t.color.accent : t.color.text
const row = listRowStyle(t, active)
const fg = active ? (row.color ?? t.color.accent) : t.color.text
return (
<Text bold={active} color={fg} inverse={active} wrap="truncate-end">
<Text backgroundColor={row.backgroundColor} bold={active} color={fg} wrap="truncate-end">
{cells.map((c, i) => (
<Text color={active ? fg : (c.color ?? t.color.text)} key={i}>
{c.text}

View file

@ -0,0 +1,112 @@
import { Box, Text } from '@hermes/ink'
import { useEffect, useState } from 'react'
import { mix } from '../lib/color.js'
/**
* Animated ASCII loaders THE loading-state primitives (session panel
* skeleton, widget apps via the SDK). A highlight band sweeps across block
* runs; rows offset their phase for a diagonal shimmer. One interval per
* composition (the parent ticks, rows are pure), colors are caller-owned
* theme tones never hardcoded.
*/
const BAND = 7
/** Pure band math: [pre, band, post] cell widths for a sweep at `phase`.
* The band enters from off-left and exits off-right, wrapping. */
export function shimmerSegments(width: number, phase: number, band = BAND): [number, number, number] {
const cycle = width + band
const start = (((phase % cycle) + cycle) % cycle) - band
const from = Math.max(0, start)
const to = Math.min(width, start + band)
return to <= from ? [width, 0, 0] : [from, to - from, width - to]
}
/** One shimmering run. Controlled: the parent owns `phase` so sibling rows
* stay in lockstep (offset it per row for the diagonal). */
export function Shimmer({
char = '▁',
color,
highlight,
phase,
width
}: {
char?: string
color: string
highlight: string
phase: number
width: number
}) {
const [pre, band, post] = shimmerSegments(width, phase)
return (
<Text>
{pre > 0 && <Text color={color}>{char.repeat(pre)}</Text>}
{band > 0 && <Text color={highlight}>{char.repeat(band)}</Text>}
{post > 0 && <Text color={color}>{char.repeat(post)}</Text>}
</Text>
)
}
/** Self-ticking phase for shimmer compositions. */
export function useShimmerPhase(tickMs = 90): number {
const [phase, setPhase] = useState(0)
useEffect(() => {
const id = setInterval(() => setPhase(p => p + 1), tickMs)
id.unref?.()
return () => clearInterval(id)
}, [tickMs])
return phase
}
/** Skeleton rows shaped like `label: value` content, diagonal shimmer.
*
* Ergonomic for generated code (the primary author is an agent):
* - `rows` explicit `[labelWidth, valueWidth][]` mirroring real layout,
* OR just a count (row widths derive from `width`, staggered).
* - colors explicit `color`/`highlight`, OR pass a theme `t` and they
* derive (muted-toward-surface base, label highlight). */
export function ShimmerRows({
color,
highlight,
rows,
t,
width = 24
}: {
color?: string
highlight?: string
rows: number | readonly (readonly [number, number])[]
t?: { color: { completionBg: string; label: string; muted: string } }
width?: number
}) {
const phase = useShimmerPhase()
const base = color ?? (t ? mix(t.color.muted, t.color.completionBg, 0.5) : '#808080')
const glow = highlight ?? t?.color.label ?? '#a0a0a0'
const spec: readonly (readonly [number, number])[] =
typeof rows === 'number'
? Array.from({ length: Math.max(1, rows) }, (_, i) => {
const label = Math.max(4, Math.round(width * 0.3) - (i % 3))
return [label, Math.max(4, width - label - 1)] as const
})
: rows
return (
<Box flexDirection="column">
{spec.map(([labelWidth, valueWidth], i) => (
<Text key={i}>
<Shimmer color={base} highlight={glow} phase={phase - i * 2} width={labelWidth} />
<Text> </Text>
<Shimmer color={base} highlight={glow} phase={phase - i * 2 - labelWidth} width={valueWidth} />
</Text>
))}
</Box>
)
}

View file

@ -10,6 +10,7 @@ import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
import type { Theme } from '../theme.js'
import { OverlayHint, useOverlayKeys, windowItems } from './overlayControls.js'
import { chipRowProps, clampOverlayWidth } from './overlayPrimitives.js'
const VISIBLE = 12
const MIN_WIDTH = 40
@ -34,6 +35,7 @@ export function ModelPicker({
allowPersistGlobal = true,
gw,
initialRefresh = false,
maxWidth,
onCancel,
onSelect,
sessionId,
@ -57,8 +59,10 @@ export function ModelPicker({
// Pin the picker to a stable width so the FloatBox parent (which shrinks-
// to-fit with alignSelf="flex-start") doesn't resize as long provider /
// model names scroll into view, and so `wrap="truncate-end"` on each row
// has an actual constraint to truncate against.
const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6))
// has an actual constraint to truncate against. Optional maxWidth lets
// grid layouts hand the picker its cell budget.
const preferredWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6))
const width = clampOverlayWidth(preferredWidth, maxWidth)
useEffect(() => {
gw.request<ModelOptionsResponse>('model.options', {
@ -593,9 +597,8 @@ export function ModelPicker({
return row ? (
<Text
bold={providerIdx === idx}
color={providerIdx === idx ? t.color.accent : dimmed ? t.color.label : t.color.muted}
inverse={providerIdx === idx}
color={dimmed ? t.color.label : t.color.muted}
{...chipRowProps(t, providerIdx === idx)}
key={p?.slug ?? `row-${idx}`}
wrap="truncate-end"
>
@ -666,9 +669,8 @@ export function ModelPicker({
return (
<Text
bold={modelIdx === idx}
color={modelIdx === idx ? t.color.accent : t.color.muted}
inverse={modelIdx === idx}
color={t.color.muted}
{...chipRowProps(t, modelIdx === idx)}
key={`${provider?.slug ?? 'prov'}:${idx}:${row}`}
wrap="truncate-end"
>
@ -697,6 +699,7 @@ interface ModelPickerProps {
allowPersistGlobal?: boolean
gw: GatewayClient
initialRefresh?: boolean
maxWidth?: number
onCancel: () => void
onSelect: (value: string) => void
sessionId: string | null

View file

@ -0,0 +1,141 @@
import { Box, Text, useStdout } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import { type ReactNode } from 'react'
import { $uiTheme } from '../app/uiStore.js'
export type OverlayZone =
| 'bottom'
| 'bottom-left'
| 'bottom-right'
| 'center'
| 'left'
| 'right'
| 'top'
| 'top-left'
| 'top-right'
interface OverlayProps {
/** Render a faux scrim behind the content (lipgloss-style: spaces + bg color). */
backdrop?: boolean
/** Background color used to paint the scrim. Defaults to `theme.color.statusBg`. */
backdropColor?: string
children: ReactNode
/** Nine CSS-grid-style zones. Defaults to `center`. */
zone?: OverlayZone
}
/**
* Viewport-level overlay primitive. Positions its child in one of nine zones
* and optionally paints a scrim behind it.
*
* Backdrop uses the canonical TUI pattern (cf. `lipgloss.Place`): each cell is
* a SPACE with a backgroundColor, so the area reads as a clean dimmed plane
* over the transcript. Ink only paints `backgroundColor` on cells with
* content, so the scrim is rendered as explicit lines of spaces a `<Box>`
* with a bg alone would be invisible. Uses stdout dims so placement is
* deterministic regardless of tree depth.
*/
export function Overlay({ backdrop = false, backdropColor, children, zone = 'center' }: OverlayProps) {
const { stdout } = useStdout()
const theme = useStore($uiTheme)
const cols = stdout?.columns ?? 80
const rows = stdout?.rows ?? 24
const [justify, align] = zoneFlex(zone)
const scrimBg = backdropColor ?? theme.color.statusBg
const scrimLine = ' '.repeat(cols)
return (
<>
{backdrop && (
<Box flexDirection="column" height={rows} left={0} position="absolute" top={0} width={cols}>
{Array.from({ length: rows }, (_, i) => (
<Text backgroundColor={scrimBg} key={i}>
{scrimLine}
</Text>
))}
</Box>
)}
<Box
alignItems={align}
flexDirection="row"
height={rows}
justifyContent={justify}
left={0}
position="absolute"
top={0}
width={cols}
>
{children}
</Box>
</>
)
}
interface DialogProps {
children: ReactNode
hint?: ReactNode
title?: string
width?: number
}
/** Bordered card with optional title + hint. Pair with `Overlay` for centered modals. */
export function Dialog({ children, hint, title, width }: DialogProps) {
const theme = useStore($uiTheme)
const innerWidth = width !== undefined ? Math.max(1, width - 6) : undefined
return (
<Box
borderColor={theme.color.primary}
borderStyle="round"
flexDirection="column"
opaque
paddingX={2}
paddingY={1}
width={width}
>
{title && (
<Box justifyContent="center" marginBottom={1} width={innerWidth}>
<Text bold color={theme.color.primary}>
{title}
</Text>
</Box>
)}
{children}
{hint && (
<Box marginTop={1}>{typeof hint === 'string' ? <Text color={theme.color.muted}>{hint}</Text> : hint}</Box>
)}
</Box>
)
}
const zoneFlex = (zone: OverlayZone): ['center' | 'flex-end' | 'flex-start', 'center' | 'flex-end' | 'flex-start'] => {
const horizontal = {
bottom: 'center',
'bottom-left': 'flex-start',
'bottom-right': 'flex-end',
center: 'center',
left: 'flex-start',
right: 'flex-end',
top: 'center',
'top-left': 'flex-start',
'top-right': 'flex-end'
} as const
const vertical = {
bottom: 'flex-end',
'bottom-left': 'flex-end',
'bottom-right': 'flex-end',
center: 'center',
left: 'center',
right: 'center',
top: 'flex-start',
'top-left': 'flex-start',
'top-right': 'flex-start'
} as const
return [horizontal[zone], vertical[zone]]
}

View file

@ -3,8 +3,33 @@ import { Text, useInput } from '@hermes/ink'
import { type ReactNode, useState } from 'react'
import type { UsageModelData } from '../gatewayTypes.js'
import { liftForContrast, mix } from '../lib/color.js'
import type { Theme } from '../theme.js'
/**
* Overlay width clamp: prefer `preferred`, honor the caller's `maxWidth`
* ABSOLUTELY (a grid cell knows its budget overflowing it clips at the
* terminal edge), and keep a usability floor only when the cap allows it.
*/
export function clampOverlayWidth(preferred: number, maxWidth?: number, min = 24): number {
const cap = maxWidth === undefined ? Number.MAX_SAFE_INTEGER : Math.max(1, Math.trunc(maxWidth))
return Math.max(Math.min(min, cap), Math.min(preferred, cap))
}
/**
* THE scrollbar treatment (transcript + overlays): thumb rides the theme
* base, accent while interacting; track recedes via an explicit blend toward
* the surface. Never SGR dim terminal-interpreted, it renders as a black
* slab on transparent profiles (terminal.background #00000000).
*/
export function scrollbarColors(t: Theme, hover: boolean, grabbed: boolean): { thumb: string; track: string } {
return {
thumb: grabbed || hover ? t.color.accent : t.color.primary,
track: mix(hover ? t.color.border : t.color.muted, t.color.completionBg, hover ? 0.25 : 0.55)
}
}
export interface MenuRowSpec {
color?: string
label: string
@ -51,11 +76,47 @@ export function useMenu(rows: MenuRowSpec[], onEscape: () => void, onKey?: (ch:
return Math.min(sel, Math.max(0, rows.length - 1))
}
/** A numbered menu row with the ▸ cursor (mirrors ClarifyPrompt). */
/**
* THE selected-row treatment for every list surface (completions popover,
* session switcher, pickers): a `selection` chip on the active row, nothing
* painted otherwise. Panels never paint their own full background floating
* surfaces are `opaque` (terminal-native canvas) and only the active row
* carries color, so list surfaces cannot disagree about "selected" and stay
* correct on any terminal background. Callers own layout; this owns color.
*/
export function listRowStyle(t: Theme, active: boolean): { backgroundColor?: string; color?: string } {
if (!active) {
return {}
}
const backgroundColor = t.color.completionCurrentBg
// The chip guarantees its own ink: a cross-polarity theme (dark palette on
// a light terminal) pairs pale text with a light chip, so lift the ink
// against the ACTUAL chip fill with the xterm algorithm.
return { backgroundColor, color: liftForContrast(t.color.text, backgroundColor, 4.5) }
}
/** Spreadable props for a selectable row: chip bg + ink + bold when active.
* Spread AFTER `color` so the chip ink wins on the active row. Replaces
* `inverse`, which swaps against the terminal's unknowable default colors
* (a black slab on transparent profiles). */
export function chipRowProps(t: Theme, active: boolean): { backgroundColor?: string; bold: boolean; color?: string } {
const row = listRowStyle(t, active)
return { backgroundColor: row.backgroundColor, bold: active, ...(row.color ? { color: row.color } : {}) }
}
/** A numbered menu row with the cursor (mirrors ClarifyPrompt). Active rows
* carry the shared list-row selection chip same treatment as completions
* and the session switcher instead of `inverse`, whose contrast depends on
* the terminal's unknowable default colors. */
export function MenuRow({ active, index, label, t }: { active: boolean; index: number; label: string; t: Theme }) {
const row = listRowStyle(t, active)
return (
<Text>
<Text bold={active} color={active ? t.color.label : t.color.muted} inverse={active}>
<Text backgroundColor={row.backgroundColor} bold={active} color={active ? (row.color ?? t.color.label) : t.color.muted}>
{active ? '▸ ' : ' '}
{index}. {label}
</Text>

View file

@ -3,6 +3,8 @@ import { type RefObject, useState } from 'react'
import type { Theme } from '../theme.js'
import { scrollbarColors } from './overlayPrimitives.js'
/**
* Mouse-draggable scrollbar bound to a `ScrollBox` ref. Re-renders off the
* parent `tick` so accordions / async content can resize the thumb without a
@ -39,8 +41,7 @@ export function OverlayScrollbar({
const vBar = (n: number) => (n > 0 ? `${'│\n'.repeat(n - 1)}` : '')
const thumbBody = `${'┃\n'.repeat(Math.max(0, thumb - 1))}`
const thumbColor = grab !== null ? t.color.primary : t.color.accent
const trackColor = hover ? t.color.border : t.color.muted
const { thumb: thumbColor, track: trackColor } = scrollbarColors(t, hover, grab !== null)
const jump = (row: number, offset: number) => {
if (!s || !scrollable) {
@ -68,24 +69,14 @@ export function OverlayScrollbar({
width={1}
>
{!scrollable ? (
<Text color={trackColor} dim>
{vBar(vp)}
</Text>
<Text color={trackColor}>{vBar(vp)}</Text>
) : (
<>
{thumbTop > 0 ? (
<Text color={trackColor} dim={!hover}>
{vBar(thumbTop)}
</Text>
) : null}
{thumbTop > 0 ? <Text color={trackColor}>{vBar(thumbTop)}</Text> : null}
<Text color={thumbColor}>{thumbBody}</Text>
{below > 0 ? (
<Text color={trackColor} dim={!hover}>
{vBar(below)}
</Text>
) : null}
{below > 0 ? <Text color={trackColor}>{vBar(below)}</Text> : null}
</>
)}
</Box>

View file

@ -6,6 +6,7 @@ import { rpcErrorMessage } from '../lib/rpc.js'
import type { Theme } from '../theme.js'
import { OverlayHint, windowItems } from './overlayControls.js'
import { chipRowProps, clampOverlayWidth } from './overlayPrimitives.js'
const VISIBLE = 10
const MIN_WIDTH = 40
@ -30,7 +31,7 @@ interface Gallery {
* (install-on-demand). The mascot lights up live once `usePet` next polls
* no restart. This is the interactive sibling of the text `/pet <slug>` path.
*/
export function PetPicker({ gw, onClose, t }: PetPickerProps) {
export function PetPicker({ gw, maxWidth, onClose, t }: PetPickerProps) {
const [gallery, setGallery] = useState<Gallery | null>(null)
const [query, setQuery] = useState('')
const [idx, setIdx] = useState(0)
@ -39,7 +40,9 @@ export function PetPicker({ gw, onClose, t }: PetPickerProps) {
const [loading, setLoading] = useState(true)
const { stdout } = useStdout()
const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6))
// Optional maxWidth lets grid layouts hand the picker its cell budget.
const preferredWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6))
const width = clampOverlayWidth(preferredWidth, maxWidth)
useEffect(() => {
gw.request<Gallery>('pet.gallery')
@ -153,7 +156,7 @@ export function PetPicker({ gw, onClose, t }: PetPickerProps) {
const tag = pet.installed ? '' : pet.curated ? ' · official' : ''
return (
<Text bold={at} color={at ? t.color.accent : t.color.muted} inverse={at} key={pet.slug} wrap="truncate-end">
<Text color={t.color.muted} {...chipRowProps(t, at)} key={pet.slug} wrap="truncate-end">
{at ? '▸ ' : ' '}
{mark} {pet.displayName}
<Text color={at ? t.color.accent : t.color.muted}>
@ -178,6 +181,7 @@ export function PetPicker({ gw, onClose, t }: PetPickerProps) {
interface PetPickerProps {
gw: GatewayClient
maxWidth?: number
onClose: () => void
t: Theme
}

View file

@ -6,6 +6,7 @@ import { rpcErrorMessage } from '../lib/rpc.js'
import type { Theme } from '../theme.js'
import { OverlayHint, useOverlayKeys, windowItems, windowOffset } from './overlayControls.js'
import { chipRowProps, clampOverlayWidth } from './overlayPrimitives.js'
const VISIBLE = 12
const MIN_WIDTH = 44
@ -39,7 +40,7 @@ const GLYPH: Record<string, string> = {
enabled: '✓'
}
export function PluginsHub({ gw, onClose, t }: PluginsHubProps) {
export function PluginsHub({ gw, maxWidth, onClose, t }: PluginsHubProps) {
const [rows, setRows] = useState<PluginRow[]>([])
const [bundledCount, setBundledCount] = useState(0)
const [userCount, setUserCount] = useState(0)
@ -50,7 +51,9 @@ export function PluginsHub({ gw, onClose, t }: PluginsHubProps) {
const [loading, setLoading] = useState(true)
const { stdout } = useStdout()
const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6))
// Optional maxWidth lets grid layouts hand the hub its cell budget.
const preferredWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6))
const width = clampOverlayWidth(preferredWidth, maxWidth)
const load = () => {
gw.request<PluginsListResponse>('plugins.manage', { action: 'list' })
@ -207,9 +210,8 @@ export function PluginsHub({ gw, onClose, t }: PluginsHubProps) {
return (
<Text
bold={active}
color={active ? t.color.accent : t.color.muted}
inverse={active}
color={t.color.muted}
{...chipRowProps(t, active)}
key={effectiveRows[lineIdx]?.name ?? row}
wrap="truncate-end"
>
@ -233,6 +235,7 @@ export function PluginsHub({ gw, onClose, t }: PluginsHubProps) {
interface PluginsHubProps {
gw: GatewayClient
maxWidth?: number
onClose: () => void
t: Theme
}

View file

@ -5,6 +5,7 @@ import { isMac } from '../lib/platform.js'
import type { Theme } from '../theme.js'
import type { ApprovalReq, ClarifyReq, ConfirmReq } from '../types.js'
import { chipRowProps } from './overlayPrimitives.js'
import { TextInput } from './textInput.js'
const APPROVAL_OPTS = ['once', 'session', 'always', 'deny'] as const
@ -129,7 +130,7 @@ export function ApprovalPrompt({ cols = 80, onChoice, req, t }: ApprovalPromptPr
{opts.map((o, i) => (
<Text key={o}>
<Text bold={sel === i} color={sel === i ? t.color.warn : t.color.muted} inverse={sel === i}>
<Text color={t.color.muted} {...chipRowProps(t, sel === i)}>
{sel === i ? '▸ ' : ' '}
{i + 1}. {LABELS[o]}
</Text>
@ -208,7 +209,7 @@ export function ClarifyPrompt({ cols = 80, onAnswer, onCancel, req, t }: Clarify
{[...choices, 'Other (type your answer)'].map((c, i) => (
<Text key={i}>
<Text bold={sel === i} color={sel === i ? t.color.label : t.color.muted} inverse={sel === i}>
<Text color={t.color.muted} {...chipRowProps(t, sel === i)}>
{sel === i ? '▸ ' : ' '}
{i + 1}. {c}
</Text>

View file

@ -6,12 +6,14 @@ import { rpcErrorMessage } from '../lib/rpc.js'
import type { Theme } from '../theme.js'
import { OverlayHint, useOverlayKeys, windowItems, windowOffset } from './overlayControls.js'
import { chipRowProps } from './overlayPrimitives.js'
import { clampOverlayWidth } from './overlayPrimitives.js'
const VISIBLE = 12
const MIN_WIDTH = 40
const MAX_WIDTH = 90
export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
export function SkillsHub({ gw, maxWidth, onClose, t }: SkillsHubProps) {
const [skillsByCat, setSkillsByCat] = useState<Record<string, string[]>>({})
const [selectedCat, setSelectedCat] = useState('')
const [catIdx, setCatIdx] = useState(0)
@ -23,7 +25,9 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
const [loading, setLoading] = useState(true)
const { stdout } = useStdout()
const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6))
const terminalWidth = Math.max(1, (stdout?.columns ?? 80) - 6)
const preferredWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, terminalWidth))
const width = clampOverlayWidth(preferredWidth, maxWidth)
useEffect(() => {
gw.request<{ skills?: Record<string, string[]> }>('skills.manage', { action: 'list' })
@ -217,13 +221,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
const idx = offset + i
return (
<Text
bold={catIdx === idx}
color={catIdx === idx ? t.color.accent : t.color.muted}
inverse={catIdx === idx}
key={row}
wrap="truncate-end"
>
<Text color={t.color.muted} {...chipRowProps(t, catIdx === idx)} key={row} wrap="truncate-end">
{catIdx === idx ? '▸ ' : ' '}
{i + 1}. {row}
</Text>
@ -253,13 +251,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
const idx = offset + i
return (
<Text
bold={skillIdx === idx}
color={skillIdx === idx ? t.color.accent : t.color.muted}
inverse={skillIdx === idx}
key={row}
wrap="truncate-end"
>
<Text color={t.color.muted} {...chipRowProps(t, skillIdx === idx)} key={row} wrap="truncate-end">
{skillIdx === idx ? '▸ ' : ' '}
{i + 1}. {row}
</Text>
@ -303,6 +295,7 @@ interface SkillInfo {
interface SkillsHubProps {
gw: GatewayClient
maxWidth?: number
onClose: () => void
t: Theme
}

View file

@ -31,8 +31,6 @@ const { Box, Text, useStdin, useInput, useStdout, stringWidth, useCursorAdvance,
const ESC = '\x1b'
const INV = `${ESC}[7m`
const INV_OFF = `${ESC}[27m`
const DIM = `${ESC}[2m`
const DIM_OFF = `${ESC}[22m`
const FWD_DEL_RE = new RegExp(`${ESC}\\[3(?:[~$^]|;)`)
const PRINTABLE = /^[ -~\u00a0-\uffff]+$/
const BRACKET_PASTE = new RegExp(`${ESC}?\\[20[01]~`, 'g')
@ -41,7 +39,33 @@ const MULTI_CLICK_MS = 500
type MinimalEnv = Record<string, string | undefined>
const invert = (s: string) => INV + s + INV_OFF
const dim = (s: string) => DIM + s + DIM_OFF
// Placeholder styling is EXPLICIT truecolor only — never SGR dim/inverse:
// both are terminal-interpreted relative to the default fg/bg, and on
// transparent profiles (terminal.background #00000000) they composite
// against a black RGB the user never sees — the hint rendered as a slab.
const HINT_FALLBACK = '#808080'
const hintRgb = (hex?: string): [number, number, number] => {
const n = parseInt((/^#([0-9a-f]{6})$/i.exec(hex ?? '')?.[1] ?? HINT_FALLBACK.slice(1)) as string, 16)
return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]
}
const colorizeHint = (s: string, hex?: string) => {
const [r, g, b] = hintRgb(hex)
return `${ESC}[38;2;${r};${g};${b}m${s}${ESC}[39m`
}
/** Synthetic placeholder cursor: a hint-colored chip with luminance-picked
* ink, standing in for the hidden hardware cursor (bubbles pattern). */
const hintCursorCell = (ch: string, hex?: string) => {
const [r, g, b] = hintRgb(hex)
const ink = 0.2126 * r + 0.7152 * g + 0.0722 * b > 140 ? '0;0;0' : '255;255;255'
return `${ESC}[48;2;${r};${g};${b}m${ESC}[38;2;${ink}m${ch}${ESC}[39m${ESC}[49m`
}
let _seg: Intl.Segmenter | null = null
const seg = () => (_seg ??= new Intl.Segmenter(undefined, { granularity: 'grapheme' }))
@ -540,6 +564,7 @@ export function TextInput({
mouseApiRef,
voiceRecordKey = DEFAULT_VOICE_RECORD_KEY,
placeholder = '',
placeholderColor,
focus = true
}: TextInputProps) {
const [cur, setCur] = useState(value.length)
@ -597,14 +622,24 @@ export function TextInput({
const boxRef = useDeclaredCursor({
line: layout.line,
column: layout.column,
active: focus && termFocus && !selected
// The placeholder state draws a synthetic cursor (see `rendered`), so the
// hardware cursor must not also be declared there — hosts paint it with
// their own cursor colors as a solid slab over the first glyph.
active: focus && termFocus && !selected && !(!display && !!placeholder)
})
// Hide the hardware cursor while a selection is active (prevents
// auto-wrap onto the next row when inverted text fills the column
// exactly) or when the terminal loses focus (suppresses the hollow-rect
// ghost most terminals draw at the parked position).
const hideHardwareCursor = focus && !!stdout?.isTTY && (!!selected || !termFocus)
// exactly), when the terminal loses focus (suppresses the hollow-rect
// ghost most terminals draw at the parked position), or while the
// placeholder is showing: hosts draw block cursors with their OWN
// cursor/cursorAccent colors, which can render as a solid slab that
// swallows the first placeholder glyph ("sk me anything…"). The
// placeholder state draws its own synthetic cursor instead (the
// bubbletea/bubbles textinput pattern: the cursor cell renders the first
// placeholder character, styled), so the hint is always fully legible.
const placeholderShowing = focus && !display && !!placeholder
const hideHardwareCursor = focus && !!stdout?.isTTY && (!!selected || !termFocus || placeholderShowing)
useEffect(() => {
if (!hideHardwareCursor || !stdout) {
@ -620,17 +655,19 @@ export function TextInput({
const nativeCursor = focus && termFocus && !selected && !!stdout?.isTTY
// Placeholder text is just a hint, not a selection — render it dim
// without inverse styling. In a TTY the hardware cursor parks at column
// 0 and visually marks the input start. Non-TTY surfaces still need the
// synthetic inverse first-char to draw a cursor at all.
// Placeholder text is just a hint, not a selection — render it in the
// theme's muted color (SGR dim as fallback). The cursor over an empty
// input is SYNTHETIC (bubbles textinput pattern): the first placeholder
// character rendered inverse-muted, so the glyph stays legible under the
// "cursor" and the block never renders as a host-colored solid slab. The
// hardware cursor is hidden for this state (see hideHardwareCursor).
const rendered = useMemo(() => {
if (!focus) {
return display || dim(placeholder)
return display || colorizeHint(placeholder, placeholderColor)
}
if (!display && placeholder) {
return nativeCursor ? dim(placeholder) : invert(placeholder[0] ?? ' ') + dim(placeholder.slice(1))
return hintCursorCell(placeholder[0] ?? ' ', placeholderColor) + colorizeHint(placeholder.slice(1), placeholderColor)
}
if (selected) {
@ -638,7 +675,7 @@ export function TextInput({
}
return nativeCursor ? display || ' ' : renderWithCursor(display, cur)
}, [cur, display, focus, nativeCursor, placeholder, selected])
}, [cur, display, focus, nativeCursor, placeholder, placeholderColor, selected])
useEffect(() => {
if (self.current) {
@ -1387,6 +1424,8 @@ interface TextInputProps {
) => { cursor: number; value: string } | Promise<{ cursor: number; value: string } | null> | null
onSubmit?: (v: string) => void
placeholder?: string
/** Hex color for placeholder text (theme muted); SGR dim when omitted. */
placeholderColor?: string
value: string
voiceRecordKey?: ParsedVoiceRecordKey
}

View file

@ -0,0 +1,272 @@
import { Box } from '@hermes/ink'
import { Fragment, memo, type ReactNode, useMemo } from 'react'
import {
type GridAreaCell,
type GridAreaItem,
type GridTrackSize,
layoutGridAreas,
layoutWidgetGrid,
type WidgetGridCell,
type WidgetGridItem,
widgetGridSpanWidth
} from '../lib/widgetGrid.js'
export interface WidgetGridRenderContext {
cell: WidgetGridCell
width: number
}
type WidgetGridChildren = ((ctx: WidgetGridRenderContext) => ReactNode) | ReactNode
/**
* A grid item with optional content. Use `children` for static or stateful
* React subtrees (including a nested `WidgetGrid`) and `render` for a width-
* aware factory; if both are provided, `render` wins.
*/
export interface WidgetGridWidget extends WidgetGridItem {
children?: WidgetGridChildren
render?: (width: number, cell: WidgetGridCell) => ReactNode
}
/**
* `WidgetGrid` lays out children into rows/cols using the same primitives as
* CSS grid: explicit `columns` count or a width-derived auto count, per-item
* `colStart` / `colSpan`, and uniform `gap` / `rowGap`. Cells clip their
* contents (`overflow: hidden`) so child overflow can never bleed into the
* neighbouring cell or break the parent border.
*/
interface WidgetGridProps {
/** Column count (equal shares) or grid-template-style track list. */
columns?: GridTrackSize[] | number
cols: number
depth?: number
gap?: number
maxColumns?: number
minColumnWidth?: number
paddingX?: number
paddingY?: number
rowGap?: number
widgets: WidgetGridWidget[]
}
const toInt = (value: number, fallback: number) => (Number.isFinite(value) ? Math.trunc(value) : fallback)
const columnCountHint = (columns: GridTrackSize[] | number | undefined) =>
Array.isArray(columns) ? columns.length : (columns ?? 0)
const inferredGap = (cols: number, columns: GridTrackSize[] | number | undefined, depth: number) => {
const count = columnCountHint(columns)
if (cols < 36 || count >= 8) {
return 0
}
if (depth > 0 || cols < 72 || count >= 4) {
return 1
}
return 2
}
const inferredPaddingX = (cols: number, depth: number) => {
if (depth <= 0 || cols < 24) {
return 0
}
return cols >= 56 ? 2 : 1
}
const inferredRowGap = (depth: number) => (depth > 0 ? 0 : 1)
export const WidgetGrid = memo(function WidgetGrid({
columns,
cols,
depth = 0,
gap,
maxColumns = 2,
minColumnWidth = 46,
paddingX,
paddingY,
rowGap,
widgets
}: WidgetGridProps) {
const safeCols = Math.max(1, toInt(cols, 1))
const safePaddingX = Math.max(0, toInt(paddingX ?? inferredPaddingX(safeCols, depth), 0))
const safePaddingY = Math.max(0, toInt(paddingY ?? 0, 0))
const innerCols = Math.max(1, safeCols - safePaddingX * 2)
const safeGap = Math.max(0, toInt(gap ?? inferredGap(innerCols, columns, depth), 0))
const safeRowGap = Math.max(0, toInt(rowGap ?? inferredRowGap(depth), 0))
const layout = useMemo(
() =>
layoutWidgetGrid({
columns,
gap: safeGap,
items: widgets.map(({ colSpan, colStart, id, span }) => ({ colSpan, colStart, id, span })),
maxColumns,
minColumnWidth,
width: innerCols
}),
[columns, innerCols, maxColumns, minColumnWidth, safeGap, widgets]
)
const widgetById = useMemo(() => new Map(widgets.map(widget => [widget.id, widget])), [widgets])
if (!layout.rows.length) {
return null
}
return (
<Box flexDirection="column" paddingX={safePaddingX} paddingY={safePaddingY} width={safeCols}>
{layout.rows.map((row, rowIdx) => (
<Box flexDirection="column" key={`row-${rowIdx}`}>
<Box flexDirection="row">
<WidgetRow cells={row} columns={layout.columns} gap={safeGap} widgetById={widgetById} />
</Box>
{safeRowGap > 0 && rowIdx < layout.rows.length - 1 ? <Box height={safeRowGap} /> : null}
</Box>
))}
</Box>
)
})
const WidgetRow = memo(function WidgetRow({
cells,
columns,
gap,
widgetById
}: {
cells: WidgetGridCell[]
columns: number[]
gap: number
widgetById: Map<string, WidgetGridWidget>
}) {
return (
<>
{cells.map((cell, idx) => {
const cursor = idx === 0 ? 0 : cells[idx - 1]!.col + cells[idx - 1]!.span
const spacerWidth =
cell.col === 0
? 0
: cursor === 0
? widgetGridSpanWidth(columns, 0, cell.col, gap) + gap
: gap + (cell.col > cursor ? widgetGridSpanWidth(columns, cursor, cell.col - cursor, gap) + gap : 0)
return (
<Fragment key={cell.id}>
{spacerWidth > 0 ? <Box flexShrink={0} width={spacerWidth} /> : null}
<WidgetCell cell={cell} widget={widgetById.get(cell.id)} />
</Fragment>
)
})}
</>
)
})
const WidgetCell = memo(function WidgetCell({ cell, widget }: { cell: WidgetGridCell; widget?: WidgetGridWidget }) {
const node =
widget?.render?.(cell.width, cell) ??
(typeof widget?.children === 'function' ? widget.children({ cell, width: cell.width }) : widget?.children) ??
null
return (
<Box flexShrink={0} overflow="hidden" width={cell.width}>
{node}
</Box>
)
})
// ── GridAreas: the two-axis workspace mode ──────────────────────────────────
type GridAreaChildren = ((cell: GridAreaCell) => ReactNode) | ReactNode
/**
* An area widget: placement (`col`/`row`/`colSpan`/`rowSpan`) plus content.
* `render` receives the solved cell (with `width`/`height` in terminal
* cells); `children` accepts a static subtree or a factory. `render` wins
* when both are given, mirroring `WidgetGridWidget`.
*/
export interface GridAreaWidget extends GridAreaItem {
children?: GridAreaChildren
render?: (cell: GridAreaCell) => ReactNode
}
interface GridAreasProps {
/** Column tracks: a count (equal shares) or explicit fixed/`fr` tracks. */
columns: GridTrackSize[] | number
gap?: number
height: number
rowGap?: number
/** Row tracks. Omitted: every row is an equal `fr` share of `height`. */
rows?: GridTrackSize[] | number
widgets: GridAreaWidget[]
width: number
}
/**
* `GridAreas` renders widgets into a fully two-dimensional grid: explicit
* column AND row tracks (fixed cells or weighted `fr` shares), `colSpan` /
* `rowSpan`, `col` / `row` pins, and dense auto-placement. Unlike the flowing
* `WidgetGrid` (which stacks rows and cannot express `rowSpan`), every cell
* here is solved to a rect and absolutely positioned inside a fixed-size box,
* so a cell can span rows the same way a merged FancyZones cell does on the
* desktop app. Requires a known `height`.
*/
export const GridAreas = memo(function GridAreas({
columns,
gap = 1,
height,
rowGap = 0,
rows,
widgets,
width
}: GridAreasProps) {
const layout = useMemo(
() =>
layoutGridAreas({
columns,
gap,
height,
items: widgets.map(({ col, colSpan, id, row, rowSpan }) => ({ col, colSpan, id, row, rowSpan })),
rowGap,
rows,
width
}),
[columns, gap, height, rowGap, rows, widgets, width]
)
const widgetById = useMemo(() => new Map(widgets.map(widget => [widget.id, widget])), [widgets])
if (!layout.cells.length) {
return null
}
return (
<Box flexDirection="column" height={layout.height} overflow="hidden" width={layout.width}>
{layout.cells.map(cell => (
<AreaCell cell={cell} key={cell.id} widget={widgetById.get(cell.id)} />
))}
</Box>
)
})
const AreaCell = memo(function AreaCell({ cell, widget }: { cell: GridAreaCell; widget?: GridAreaWidget }) {
const node =
widget?.render?.(cell) ?? (typeof widget?.children === 'function' ? widget.children(cell) : widget?.children) ?? null
return (
<Box
height={cell.height}
left={cell.x}
overflow="hidden"
position="absolute"
top={cell.y}
width={cell.width}
>
{node}
</Box>
)
})

View file

@ -55,6 +55,8 @@ gw.start()
const dumpNotice = (snap: MemorySnapshot, dump: HeapDumpResult | null) =>
`hermes-tui: ${snap.level} memory (${formatBytes(snap.heapUsed)}) — auto heap dump → ${dump?.heapPath ?? dump?.diagPath ?? '(failed)'}\n`
let consecutiveDeadStreamErrors = 0
setupGracefulExit({
cleanups: [
() => {
@ -67,7 +69,31 @@ setupGracefulExit({
const message = err instanceof Error ? `${err.name}: ${err.message}\n${err.stack ?? ''}` : String(err)
recordParentLifecycle(`${scope}: ${message.split('\n')[0]?.slice(0, 400) ?? ''}`)
process.stderr.write(`hermes-tui lifecycle ${scope}: ${message.slice(0, 2000)}\n`)
// A dead PTY (terminal tab closed, SSH dropped without SIGHUP) turns every
// stdout/stderr write into EIO/EPIPE. Swallowing those here made the parent
// a zombie: Ink's render loop throws once a second, each throw lands back
// in this handler, and the crash log fills with `write EIO` forever while
// the gateway child keeps running. Bail out for real after a few in a row.
const code = (err as NodeJS.ErrnoException)?.code
if (code === 'EIO' || code === 'EPIPE') {
if (++consecutiveDeadStreamErrors >= 5) {
recordParentLifecycle(`dead output stream (${code} x${consecutiveDeadStreamErrors}) → exiting`)
void gw.kill('dead-output-stream')
process.exit(1)
}
return
}
consecutiveDeadStreamErrors = 0
try {
process.stderr.write(`hermes-tui lifecycle ${scope}: ${message.slice(0, 2000)}\n`)
} catch {
// stderr may be the dead stream itself.
}
},
onSignal: signal => {
// The next line in the crash log is the child's `=== SIGTERM received ===`

View file

@ -7,7 +7,11 @@ export interface GatewaySkin {
banner_logo?: string
branding?: Record<string, string>
colors?: Record<string, string>
/** Hand-tuned palette for dark terminals (light-authored skins). */
dark_colors?: Record<string, string>
help_header?: string
/** Hand-tuned palette for light terminals (dark-authored skins). */
light_colors?: Record<string, string>
tool_prefix?: string
}
@ -103,6 +107,9 @@ export interface ConfigDisplayConfig {
// validation anyway.
tui_status_indicator?: string
tui_statusbar?: 'bottom' | 'off' | 'on' | 'top' | boolean
/** Theme mode pin: 'light' / 'dark' beat background auto-detection; 'auto'
* (default) trusts the OSC-11 probe + env signals. */
tui_theme?: string
}
export interface ConfigVoiceConfig {
@ -121,6 +128,9 @@ export interface ConfigFullResponse {
}
export interface ConfigMtimeResponse {
/** Revision hash of MCP-relevant config sections; reload MCP only when it
* changes (cosmetic writes like /skin must not trigger reconnects). */
mcp_rev?: string
mtime?: number
}

View file

@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest'
import { color, contrastRatio, ensureContrast, mix, parseColor, readableOn, relativeLuminance, toHex } from './color.js'
describe('parseColor', () => {
it('parses hex6, hex3 and rgb() forms', () => {
expect(parseColor('#1a2b3c')).toEqual([0x1a, 0x2b, 0x3c])
expect(parseColor('1a2b3c')).toEqual([0x1a, 0x2b, 0x3c])
expect(parseColor('#abc')).toEqual([0xaa, 0xbb, 0xcc])
expect(parseColor('rgb(220,255,220)')).toEqual([220, 255, 220])
expect(parseColor('rgba(10, 20, 30, 0.5)')).toEqual([10, 20, 30])
})
it('rejects garbage', () => {
expect(parseColor('')).toBeNull()
expect(parseColor('ansi256(245)')).toBeNull()
expect(parseColor('#12345')).toBeNull()
})
})
describe('mix', () => {
it('lerps in sRGB and round-trips through toHex', () => {
expect(mix('#000000', '#ffffff', 0.5)).toBe('#808080')
expect(mix('#ff0000', '#0000ff', 0)).toBe('#ff0000')
expect(mix('#ff0000', '#0000ff', 1)).toBe('#0000ff')
expect(toHex(parseColor(mix('#123456', '#654321', 0.3))!)).toBe(mix('#123456', '#654321', 0.3))
})
it('passes unparseable inputs through unchanged', () => {
expect(mix('ansi256(245)', '#ffffff', 0.5)).toBe('ansi256(245)')
expect(mix('#ff0000', 'nope', 0.5)).toBe('#ff0000')
})
})
describe('contrast', () => {
it('measures WCAG ratios at the anchors', () => {
expect(contrastRatio('#000000', '#ffffff')).toBeCloseTo(21, 0)
expect(contrastRatio('#ffffff', '#ffffff')).toBeCloseTo(1, 5)
expect(relativeLuminance('#ffffff')).toBeCloseTo(1, 5)
expect(relativeLuminance('#000000')).toBeCloseTo(0, 5)
})
it('readableOn picks the ink pole', () => {
expect(readableOn('#ffffff')).toBe('#000000')
expect(readableOn('#101014')).toBe('#ffffff')
})
it('ensureContrast lifts failing colors monotonically and leaves passing ones alone', () => {
const pale = '#FFF8DC'
const fixed = ensureContrast(pale, '#ffffff', 3.9)
expect(contrastRatio(fixed, '#ffffff')!).toBeGreaterThanOrEqual(3.9)
expect(ensureContrast('#3D2F13', '#ffffff', 3.9)).toBe('#3D2F13')
expect(ensureContrast('ansi256(245)', '#ffffff', 3.9)).toBe('ansi256(245)')
})
})
describe('color() chain', () => {
it('composes the ladder operations', () => {
const out = color('#F1E6CF').mix('#101014', 0.35).mix('#DD4A3A', 0.18).ensureContrast('#101014', 2.8).hex()
expect(out).toMatch(/^#[0-9a-f]{6}$/)
expect(contrastRatio(out, '#101014')!).toBeGreaterThanOrEqual(2.8)
expect(color('#808080').luminance()).toBeCloseTo(relativeLuminance('#808080')!, 10)
})
})

324
ui-tui/src/lib/color.ts Normal file
View file

@ -0,0 +1,324 @@
/**
* THE color primitive every color computation in the TUI goes through this
* module (the twin of the desktop app's `src/themes/color.ts`). No component
* or theme code does its own hex parsing or channel math: parse, mix, measure
* and fix colors here, so tone ladders, contrast floors and one-off UI needs
* all share one set of semantics.
*
* Mixing is an sRGB lerp deliberately, for byte parity with the desktop's
* `color-mix(in srgb, ...)` ladder in styles.css.
*/
export type Rgb = readonly [number, number, number]
const HEX6_RE = /^#?([0-9a-f]{6})$/i
const HEX3_RE = /^#?([0-9a-f]{3})$/i
const RGB_FN_RE = /^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})/i
const clampChannel = (v: number) => Math.max(0, Math.min(255, Math.round(v)))
/** Parse `#rgb`, `#rrggbb` or `rgb(r,g,b)` → channels. Null for anything else. */
export function parseColor(input: string): Rgb | null {
const value = input.trim()
let m = HEX6_RE.exec(value)
if (m) {
const n = parseInt(m[1]!, 16)
return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]
}
m = HEX3_RE.exec(value)
if (m) {
const [r, g, b] = m[1]!
return [parseInt(r! + r!, 16), parseInt(g! + g!, 16), parseInt(b! + b!, 16)]
}
m = RGB_FN_RE.exec(value)
if (m) {
return [clampChannel(Number(m[1])), clampChannel(Number(m[2])), clampChannel(Number(m[3]))]
}
return null
}
export const toHex = (rgb: Rgb): string =>
'#' + rgb.map(c => clampChannel(c).toString(16).padStart(2, '0')).join('')
/** sRGB lerp `a → b` by `t` in [0,1]. Unparseable inputs return `a` unchanged. */
export function mix(a: string, b: string, t: number): string {
const pa = parseColor(a)
const pb = parseColor(b)
if (!pa || !pb) {
return a
}
return toHex([pa[0] + (pb[0] - pa[0]) * t, pa[1] + (pb[1] - pa[1]) * t, pa[2] + (pb[2] - pa[2]) * t])
}
function channelLuminance(value: number): number {
const normalized = value / 255
return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4
}
/** WCAG relative luminance in [0,1]. Null when unparseable. */
export function relativeLuminance(color: string): null | number {
const rgb = parseColor(color)
return rgb ? 0.2126 * channelLuminance(rgb[0]) + 0.7152 * channelLuminance(rgb[1]) + 0.0722 * channelLuminance(rgb[2]) : null
}
/** WCAG contrast ratio between two colors (121). Null when unparseable. */
export function contrastRatio(a: string, b: string): null | number {
const la = relativeLuminance(a)
const lb = relativeLuminance(b)
if (la === null || lb === null) {
return null
}
const [hi, lo] = la >= lb ? [la, lb] : [lb, la]
return (hi + 0.05) / (lo + 0.05)
}
/** The readable ink pole for a given background (desktop `readableOn`). */
export function readableOn(bg: string): '#000000' | '#ffffff' {
return (relativeLuminance(bg) ?? 0) > 0.5 ? '#000000' : '#ffffff'
}
/**
* Step-mix `color` toward the readable pole of `bg` until the contrast ratio
* clears `min` (desktop `ensureContrast`). Each step re-mixes from the
* ORIGINAL color so hue decays linearly, not exponentially. Returns the
* original when it already passes or isn't parseable.
*/
export function ensureContrast(color: string, bg: string, min: number): string {
if (relativeLuminance(bg) === null || parseColor(color) === null) {
return color
}
const pole = readableOn(bg)
let current = color
for (let step = 0; step <= 20; step++) {
const ratio = contrastRatio(current, bg)
if (ratio === null || ratio >= min) {
return current
}
current = mix(color, pole, Math.min(1, (step + 1) * 0.05))
}
return current
}
/**
* xterm.js's minimum-contrast algorithm (Color.ts reduce/increaseLuminance),
* ported faithfully: multiplicative 10% channel steps toward the readable
* pole, which preserves channel RATIOS (hue and perceived chroma) far better
* than mixing toward black/white. This is the display shim's lift the same
* math terminals themselves use, so a palette lifted here matches what hosts
* like VS Code/Cursor produce when their own minimumContrastRatio kicks in.
* Colors already at or above `ratio` pass through byte-identical.
*/
export function liftForContrast(color: string, bg: string, ratio: number): string {
const fg = parseColor(color)
const bgLum = relativeLuminance(bg)
if (!fg || bgLum === null) {
return color
}
// Byte-identical passthrough when the color already clears the ratio —
// the authored value is the design; only failing colors get touched.
if ((contrastRatio(color, bg) ?? 21) >= ratio) {
return color
}
let [r, g, b] = fg
if (bgLum > 0.5) {
// reduceLuminance: darken multiplicatively.
let cr = contrastRatio(toHex([r, g, b]), bg) ?? 21
while (cr < ratio && (r > 0 || g > 0 || b > 0)) {
r -= Math.ceil(r * 0.1)
g -= Math.ceil(g * 0.1)
b -= Math.ceil(b * 0.1)
cr = contrastRatio(toHex([r, g, b]), bg) ?? 21
}
} else {
// increaseLuminance: brighten toward white in 10% remaining-headroom steps.
let cr = contrastRatio(toHex([r, g, b]), bg) ?? 21
while (cr < ratio && (r < 255 || g < 255 || b < 255)) {
r = Math.min(255, r + Math.ceil((255 - r) * 0.1))
g = Math.min(255, g + Math.ceil((255 - g) * 0.1))
b = Math.min(255, b + Math.ceil((255 - b) * 0.1))
cr = contrastRatio(toHex([r, g, b]), bg) ?? 21
}
}
return toHex([r, g, b])
}
/** Recede toward the background pole (opposite of `readableOn`). */
export const lighten = (color: string, t: number) => mix(color, '#ffffff', t)
export const darken = (color: string, t: number) => mix(color, '#000000', t)
/** The luminance-weighted gray of a color (its perceptual brightness). */
export function grayOf(color: string): string {
const rgb = parseColor(color)
if (!rgb) {
return color
}
const gray = clampChannel(0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2])
return toHex([gray, gray, gray])
}
/** Pull a color toward its own gray by `s` in [0,1] (1 = fully gray). */
export const desaturate = (color: string, s: number) => mix(color, grayOf(color), s)
/** RGB → HSL, channels in [0,1]. */
export function toHsl(rgb: Rgb): [number, number, number] {
const r = rgb[0] / 255
const g = rgb[1] / 255
const b = rgb[2] / 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
const l = (max + min) / 2
if (max === min) {
return [0, 0, l]
}
const d = max - min
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
const h = max === r ? (g - b) / d + (g < b ? 6 : 0) : max === g ? (b - r) / d + 2 : (r - g) / d + 4
return [h / 6, s, l]
}
const hueChannel = (p: number, q: number, t: number): number => {
const tt = t < 0 ? t + 1 : t > 1 ? t - 1 : t
if (tt < 1 / 6) {
return p + (q - p) * 6 * tt
}
if (tt < 1 / 2) {
return q
}
if (tt < 2 / 3) {
return p + (q - p) * (2 / 3 - tt) * 6
}
return p
}
/** HSL → RGB, inputs in [0,1]. */
export function fromHsl(h: number, s: number, l: number): Rgb {
if (s === 0) {
const v = Math.round(l * 255)
return [v, v, v]
}
const q = l < 0.5 ? l * (1 + s) : l + s - l * s
const p = 2 * l - q
return [
Math.round(hueChannel(p, q, h + 1 / 3) * 255),
Math.round(hueChannel(p, q, h) * 255),
Math.round(hueChannel(p, q, h - 1 / 3) * 255)
]
}
/**
* Re-tone a color while PRESERVING its hue: clamp saturation into
* [minSaturation, 1] and pin lightness. This is how a light-terminal variant
* of a pastel dark-terminal accent stays vivid darkening by mixing toward
* black muddies pastels (kills saturation with lightness); re-toning keeps
* the color identity and moves only where it sits.
*/
export function retone(color: string, lightness: number, minSaturation = 0): string {
const rgb = parseColor(color)
if (!rgb) {
return color
}
const [h, s] = toHsl(rgb)
return toHex(fromHsl(h, Math.max(s, minSaturation), lightness))
}
/** Multiply HSL saturation by `factor` (clamped to 1), hue/lightness fixed. */
export function boostSaturation(color: string, factor: number): string {
const rgb = parseColor(color)
if (!rgb) {
return color
}
const [h, s, l] = toHsl(rgb)
if (s === 0) {
return color
}
return toHex(fromHsl(h, Math.min(1, s * factor), l))
}
/**
* Chainable form for multi-step derivations, e.g.
* `color(text).mix(bg, 0.35).mix(accent, 0.18).ensureContrast(bg, 2.8).hex()`.
* Unparseable inputs pass through every operation unchanged.
*/
export function color(value: string): ColorChain {
return new ColorChain(value)
}
class ColorChain {
constructor(private readonly value: string) {}
mix(other: string, t: number): ColorChain {
return new ColorChain(mix(this.value, other, t))
}
lighten(t: number): ColorChain {
return new ColorChain(lighten(this.value, t))
}
darken(t: number): ColorChain {
return new ColorChain(darken(this.value, t))
}
ensureContrast(bg: string, min: number): ColorChain {
return new ColorChain(ensureContrast(this.value, bg, min))
}
luminance(): null | number {
return relativeLuminance(this.value)
}
contrastOn(bg: string): null | number {
return contrastRatio(this.value, bg)
}
hex(): string {
return this.value
}
}

128
ui-tui/src/lib/themeBoot.ts Normal file
View file

@ -0,0 +1,128 @@
/**
* Flash-free theme boot the TUI port of the desktop app's
* `hermes-boot-background` / `hermes-boot-color-scheme` localStorage keys.
*
* Theme resolution is asynchronous by nature (gateway skin arrives after
* connect; the OSC-11 background probe answers after the first frame; the
* config mode pin arrives with config sync), so without a cache every launch
* repaints through default-dark skin detected-mode. This module persists
* the LAST RESOLVED theme + background to disk and replays them as the very
* first frame, so a stable setup renders correctly from paint one and the
* async signals merely confirm it.
*
* The cache is a hint, never an authority: explicit env pins beat it, and
* every later signal overwrites it (then persists the new answer).
*/
import { readFileSync, renameSync, writeFileSync } from 'fs'
import { homedir } from 'os'
import { join } from 'path'
import type { Theme } from '../theme.js'
interface BootThemeFile {
/** The resolved background hex that detection settled on, if any. */
background?: string
/** The fully-resolved Theme (palette + brand) from the last session. */
theme?: Theme
version: 1
}
// Profile-aware: the Python launcher exports HERMES_HOME (set by
// _apply_profile_override) before spawning the TUI. Falling back to
// ~/.hermes matches get_hermes_home()'s default.
const bootFilePath = () => join(process.env.HERMES_HOME ?? join(homedir(), '.hermes'), 'tui-theme-boot.json')
// Never touch the user's real ~/.hermes from test runs (the TS suite has no
// HERMES_HOME isolation fixture).
const isTestRun = () => !!process.env.VITEST || process.env.NODE_ENV === 'test'
const looksLikeTheme = (value: unknown): value is Theme => {
if (typeof value !== 'object' || value === null) {
return false
}
const theme = value as Partial<Theme>
return (
typeof theme.color === 'object' &&
theme.color !== null &&
typeof theme.color.text === 'string' &&
typeof theme.color.primary === 'string' &&
typeof theme.brand === 'object' &&
theme.brand !== null &&
typeof theme.brand.name === 'string'
)
}
/** Read the cached boot theme. Null on first launch / damage / test runs. */
export function readBootTheme(): { background?: string; theme: Theme } | null {
if (isTestRun()) {
return null
}
try {
const raw = JSON.parse(readFileSync(bootFilePath(), 'utf8')) as BootThemeFile
if (raw.version !== 1 || !looksLikeTheme(raw.theme)) {
return null
}
return { background: typeof raw.background === 'string' ? raw.background : undefined, theme: raw.theme }
} catch {
return null
}
}
let writeTimer: NodeJS.Timeout | null = null
/** Persist the resolved theme (debounced, atomic, fire-and-forget). */
export function writeBootTheme(theme: Theme, background?: string): void {
if (isTestRun()) {
return
}
if (writeTimer) {
clearTimeout(writeTimer)
}
writeTimer = setTimeout(() => {
writeTimer = null
try {
const payload: BootThemeFile = { background, theme, version: 1 }
const path = bootFilePath()
const tmp = `${path}.tmp`
writeFileSync(tmp, JSON.stringify(payload))
renameSync(tmp, path)
} catch {
// Cache write failures are cosmetic — next launch just flashes once.
}
}, 400)
writeTimer.unref?.()
}
/**
* Boot-time seeding, run once at module load (imported by uiStore before the
* first render): make the cached background visible to `detectLightMode`
* unless an explicit signal already outranks it.
*/
const boot = readBootTheme()
if (
boot?.background &&
// Never seed the untrusted "unset default" fingerprint — a cache written
// before the distrust rule existed must not poison this session's
// detection (it would also suppress the macOS-appearance fallback).
boot.background.toLowerCase() !== '#000000' &&
!process.env.HERMES_TUI_BACKGROUND &&
!process.env.HERMES_TUI_THEME &&
!process.env.HERMES_TUI_LIGHT
) {
process.env.HERMES_TUI_BACKGROUND = boot.background
}
/** The cached theme for the first frame, or null on first launch. */
export const bootTheme: Theme | null = boot?.theme ?? null

View file

@ -0,0 +1,505 @@
export interface WidgetGridItem {
colSpan?: number
colStart?: number
id: string
span?: number
}
export interface WidgetGridCell {
col: number
id: string
span: number
width: number
}
export interface WidgetGridLayout {
columnCount: number
columns: number[]
rows: WidgetGridCell[][]
}
export interface WidgetGridLayoutOptions {
/**
* Explicit column count (equal shares) or a grid-template-style track list
* (fixed cell counts / weighted `fr` shares). Omitted: auto from width.
*/
columns?: GridTrackSize[] | number
gap?: number
items: WidgetGridItem[]
maxColumns?: number
minColumnWidth?: number
width: number
}
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value))
const toInt = (value: number, fallback: number) => {
if (!Number.isFinite(value)) {
return fallback
}
return Math.trunc(value)
}
const columnCountForWidth = (width: number, minColumnWidth: number, gap: number, maxColumns: number) => {
const safeWidth = Math.max(1, toInt(width, 1))
const safeMinWidth = Math.max(1, toInt(minColumnWidth, 1))
const safeGap = Math.max(0, toInt(gap, 0))
const safeMaxColumns = Math.max(1, toInt(maxColumns, 1))
const count = Math.floor((safeWidth + safeGap) / (safeMinWidth + safeGap))
return clamp(count || 1, 1, safeMaxColumns)
}
const buildColumnWidths = (width: number, columnCount: number, gap: number) =>
resolveGridTracks(width, gap, Array.from({ length: Math.max(1, toInt(columnCount, 1)) }, () => ({ fr: 1 })))
const spanWidth = (columns: number[], colStart: number, span: number, gap: number) => {
const end = Math.min(columns.length, colStart + span)
const width = columns.slice(colStart, end).reduce((acc, value) => acc + value, 0)
const safeGap = Math.max(0, toInt(gap, 0))
return width + safeGap * Math.max(0, end - colStart - 1)
}
export const widgetGridSpanWidth = spanWidth
const itemSpan = (item: WidgetGridItem, columnCount: number) =>
clamp(toInt(item.colSpan ?? item.span ?? 1, 1), 1, columnCount)
const itemColStart = (item: WidgetGridItem, columnCount: number, span: number) => {
if (item.colStart === undefined) {
return null
}
return clamp(toInt(item.colStart, 0), 0, Math.max(0, columnCount - span))
}
const rangeIsFree = (occupied: boolean[], colStart: number, span: number) => {
for (let col = colStart; col < colStart + span; col++) {
if (occupied[col]) {
return false
}
}
return true
}
const occupyRange = (occupied: boolean[], colStart: number, span: number) => {
for (let col = colStart; col < colStart + span; col++) {
occupied[col] = true
}
}
const firstFreeCol = (occupied: boolean[], span: number) => {
for (let col = 0; col <= occupied.length - span; col++) {
if (rangeIsFree(occupied, col, span)) {
return col
}
}
return null
}
const sortRow = (row: WidgetGridCell[]) => row.sort((a, b) => a.col - b.col)
export function layoutWidgetGrid({
columns: requestedColumns,
gap = 1,
items,
maxColumns = 3,
minColumnWidth = 28,
width
}: WidgetGridLayoutOptions): WidgetGridLayout {
const safeGap = Math.max(0, toInt(gap, 1))
const safeWidth = Math.max(1, toInt(width, 1))
const maxDrawableColumns = safeGap > 0 ? Math.max(1, Math.floor((safeWidth + safeGap) / (safeGap + 1))) : safeWidth
const trackList = Array.isArray(requestedColumns) && requestedColumns.length ? requestedColumns : null
const columnCount = trackList
? trackList.length
: requestedColumns === undefined || Array.isArray(requestedColumns)
? columnCountForWidth(safeWidth, minColumnWidth, safeGap, maxColumns)
: clamp(toInt(requestedColumns, 1), 1, maxDrawableColumns)
const columns = trackList
? resolveGridTracks(safeWidth, safeGap, trackList)
: buildColumnWidths(width, columnCount, safeGap)
const rows: WidgetGridCell[][] = []
let row: WidgetGridCell[] = []
let occupied = Array.from({ length: columnCount }, () => false)
const pushRow = () => {
rows.push(sortRow(row))
row = []
occupied = Array.from({ length: columnCount }, () => false)
}
for (const item of items) {
const wantedSpan = itemSpan(item, columnCount)
const explicitCol = itemColStart(item, columnCount, wantedSpan)
let col = explicitCol ?? firstFreeCol(occupied, wantedSpan)
if (col === null || (explicitCol !== null && !rangeIsFree(occupied, explicitCol, wantedSpan))) {
if (row.length > 0) {
pushRow()
}
col = explicitCol ?? 0
}
row.push({
col,
id: item.id,
span: wantedSpan,
width: spanWidth(columns, col, wantedSpan, safeGap)
})
occupyRange(occupied, col, wantedSpan)
}
if (row.length > 0) {
rows.push(sortRow(row))
}
return { columnCount, columns, rows }
}
// ── Track solver (grid-template-columns/rows for character cells) ──────────
//
// A track is either a fixed cell count (`number`) or a fractional share
// (`{ fr, min? }`) of whatever space the fixed tracks leave over — the same
// fixed-vs-flex split the desktop pane-shell's track model uses, solved in
// integer terminal cells. `min` defaults to 1 so a track never disappears.
export type GridTrackSize = number | { fr: number; min?: number }
const trackFr = (track: GridTrackSize) => (typeof track === 'number' ? 0 : Math.max(0.0001, track.fr))
const trackMin = (track: GridTrackSize) => (typeof track === 'number' ? 1 : Math.max(1, toInt(track.min ?? 1, 1)))
/** Floor-divide `budget` across `weights`, spreading the remainder left-to-right. */
const distributeByWeight = (budget: number, weights: number[]) => {
const total = weights.reduce((acc, w) => acc + w, 0)
if (total <= 0 || budget <= 0) {
return weights.map(() => 0)
}
const shares = weights.map(w => Math.floor((budget * w) / total))
let remainder = budget - shares.reduce((acc, s) => acc + s, 0)
for (let i = 0; remainder > 0 && i < shares.length; i++, remainder--) {
shares[i]! += 1
}
return shares
}
/**
* Solve track sizes for a `total`-cell axis with `gap` cells between tracks.
* Fixed tracks take their size; `fr` tracks share the leftover by weight,
* re-pinning any track that falls below its `min` and re-solving the rest.
* Every track ends up 1 cell; when the axis genuinely can't fit, the
* overflow is shaved off the trailing tracks so the sum never exceeds the
* drawable width unless every track is already at 1.
*/
export function resolveGridTracks(total: number, gap: number, tracks: GridTrackSize[]): number[] {
const count = tracks.length
if (!count) {
return []
}
const safeGap = Math.max(0, toInt(gap, 0))
const usable = Math.max(count, toInt(total, count) - safeGap * (count - 1))
const sizes = Array.from({ length: count }, () => 0)
let remaining = usable
let unpinned: number[] = []
tracks.forEach((track, idx) => {
if (typeof track === 'number') {
sizes[idx] = Math.max(1, toInt(track, 1))
remaining -= sizes[idx]!
} else {
unpinned.push(idx)
}
})
while (unpinned.length) {
const shares = distributeByWeight(Math.max(0, remaining), unpinned.map(idx => trackFr(tracks[idx]!)))
const violating = unpinned.filter((idx, i) => shares[i]! < trackMin(tracks[idx]!))
if (!violating.length) {
unpinned.forEach((idx, i) => {
sizes[idx] = shares[i]!
})
break
}
for (const idx of violating) {
sizes[idx] = trackMin(tracks[idx]!)
remaining -= sizes[idx]!
}
unpinned = unpinned.filter(idx => !violating.includes(idx))
}
let overflow = sizes.reduce((acc, s) => acc + s, 0) - usable
for (let idx = count - 1; idx >= 0 && overflow > 0; idx--) {
const give = Math.min(overflow, sizes[idx]! - 1)
sizes[idx] -= give
overflow -= give
}
return sizes
}
// ── 2D area layout (the workspace mode) ────────────────────────────────────
//
// `layoutGridAreas` is the full two-axis grid: explicit column AND row tracks,
// items with `col`/`row` pins and `colSpan`/`rowSpan`, dense first-fit
// auto-placement, and each cell solved to an absolute `{ x, y, width, height }`
// rect. It is the terminal-cell equivalent of the desktop zone editor's
// `GridLayout` (rowPercents / columnPercents / cellChildMap with merged
// spans) — the renderer absolutely positions each rect, which is what makes
// `rowSpan` representable at all under Yoga flexbox.
export interface GridAreaItem {
id: string
/** Explicit column start (0-based). Explicitly pinned items may overlap. */
col?: number
colSpan?: number
/** Explicit row start (0-based). Rows grow implicitly as needed. */
row?: number
rowSpan?: number
}
export interface GridAreaCell {
col: number
colSpan: number
height: number
id: string
row: number
rowSpan: number
width: number
x: number
y: number
}
export interface GridAreasLayout {
cells: GridAreaCell[]
columnCount: number
columnSizes: number[]
height: number
rowCount: number
rowSizes: number[]
width: number
}
export interface GridAreasOptions {
/** Column tracks — a count (equal `fr` shares) or explicit track list. */
columns: GridTrackSize[] | number
gap?: number
height: number
items: GridAreaItem[]
rowGap?: number
/** Row tracks. Omitted or short: implicit rows are `{ fr: 1 }`. */
rows?: GridTrackSize[] | number
width: number
}
const normalizeTracks = (tracks: GridTrackSize[] | number | undefined, fallbackCount: number): GridTrackSize[] => {
if (Array.isArray(tracks) && tracks.length) {
return tracks
}
const count = Math.max(1, toInt(typeof tracks === 'number' ? tracks : fallbackCount, 1))
return Array.from({ length: count }, () => ({ fr: 1 }))
}
interface PlacedArea {
col: number
colSpan: number
id: string
row: number
rowSpan: number
}
const ensureOccupancyRows = (occupied: boolean[][], rowCount: number, columnCount: number) => {
while (occupied.length < rowCount) {
occupied.push(Array.from({ length: columnCount }, () => false))
}
}
const areaIsFree = (occupied: boolean[][], row: number, col: number, rowSpan: number, colSpan: number) => {
ensureOccupancyRows(occupied, row + rowSpan, occupied[0]?.length ?? 1)
for (let r = row; r < row + rowSpan; r++) {
for (let c = col; c < col + colSpan; c++) {
if (occupied[r]![c]) {
return false
}
}
}
return true
}
const occupyArea = (occupied: boolean[][], row: number, col: number, rowSpan: number, colSpan: number) => {
ensureOccupancyRows(occupied, row + rowSpan, occupied[0]?.length ?? 1)
for (let r = row; r < row + rowSpan; r++) {
for (let c = col; c < col + colSpan; c++) {
occupied[r]![c] = true
}
}
}
/** Dense first-fit auto-placement (CSS `grid-auto-flow: row dense`). */
const placeGridItems = (items: GridAreaItem[], columnCount: number): PlacedArea[] => {
const occupied: boolean[][] = [Array.from({ length: columnCount }, () => false)]
return items.map(item => {
const colSpan = clamp(toInt(item.colSpan ?? 1, 1), 1, columnCount)
const rowSpan = Math.max(1, toInt(item.rowSpan ?? 1, 1))
const pinnedCol = item.col === undefined ? null : clamp(toInt(item.col, 0), 0, columnCount - colSpan)
const pinnedRow = item.row === undefined ? null : Math.max(0, toInt(item.row, 0))
let row: number
let col: number
if (pinnedRow !== null && pinnedCol !== null) {
row = pinnedRow
col = pinnedCol
} else if (pinnedRow !== null) {
// Pinned row: first free column run, overlapping at col 0 when full.
col = 0
for (let c = 0; c <= columnCount - colSpan; c++) {
if (areaIsFree(occupied, pinnedRow, c, rowSpan, colSpan)) {
col = c
break
}
}
row = pinnedRow
} else {
// Auto (or pinned col): scan row-major for the first fitting rect. New
// rows are always empty, so the scan terminates.
row = 0
col = pinnedCol ?? 0
for (let r = 0; ; r++) {
const found =
pinnedCol !== null
? areaIsFree(occupied, r, pinnedCol, rowSpan, colSpan)
? pinnedCol
: null
: (() => {
for (let c = 0; c <= columnCount - colSpan; c++) {
if (areaIsFree(occupied, r, c, rowSpan, colSpan)) {
return c
}
}
return null
})()
if (found !== null) {
row = r
col = found
break
}
}
}
occupyArea(occupied, row, col, rowSpan, colSpan)
return { col, colSpan, id: item.id, row, rowSpan }
})
}
const trackOffsets = (sizes: number[], gap: number) => {
const offsets: number[] = []
let cursor = 0
for (const size of sizes) {
offsets.push(cursor)
cursor += size + gap
}
return offsets
}
const spanSize = (sizes: number[], start: number, span: number, gap: number) => {
const end = Math.min(sizes.length, start + span)
return sizes.slice(start, end).reduce((acc, s) => acc + s, 0) + gap * Math.max(0, end - start - 1)
}
export function layoutGridAreas({
columns,
gap = 1,
height,
items,
rowGap = 0,
rows,
width
}: GridAreasOptions): GridAreasLayout {
const safeGap = Math.max(0, toInt(gap, 1))
const safeRowGap = Math.max(0, toInt(rowGap, 0))
const safeWidth = Math.max(1, toInt(width, 1))
const safeHeight = Math.max(1, toInt(height, 1))
const columnTracks = normalizeTracks(columns, 1)
const columnCount = columnTracks.length
const placed = placeGridItems(items, columnCount)
const placedRowCount = placed.reduce((acc, area) => Math.max(acc, area.row + area.rowSpan), 0)
const explicitRowTracks = rows === undefined ? [] : normalizeTracks(rows, 1)
const rowCount = Math.max(1, explicitRowTracks.length, placedRowCount)
const rowTracks: GridTrackSize[] = Array.from(
{ length: rowCount },
(_, idx) => explicitRowTracks[idx] ?? { fr: 1 }
)
const columnSizes = resolveGridTracks(safeWidth, safeGap, columnTracks)
const rowSizes = resolveGridTracks(safeHeight, safeRowGap, rowTracks)
const columnStarts = trackOffsets(columnSizes, safeGap)
const rowStarts = trackOffsets(rowSizes, safeRowGap)
const cells = placed.map(area => {
const row = Math.min(area.row, rowCount - 1)
return {
col: area.col,
colSpan: area.colSpan,
height: spanSize(rowSizes, row, area.rowSpan, safeRowGap),
id: area.id,
row,
rowSpan: area.rowSpan,
width: spanSize(columnSizes, area.col, area.colSpan, safeGap),
x: columnStarts[area.col] ?? 0,
y: rowStarts[row] ?? 0
}
})
return {
cells,
columnCount,
columnSizes,
height: safeHeight,
rowCount,
rowSizes,
width: safeWidth
}
}

View file

@ -1,3 +1,5 @@
import { desaturate, grayOf, liftForContrast, mix, parseColor, relativeLuminance, toHex } from './lib/color.js'
export interface ThemeColors {
primary: string
accent: string
@ -52,31 +54,13 @@ export interface Theme {
}
// ── Color math ───────────────────────────────────────────────────────
//
// All generic color computation lives in lib/color.ts (the color primitive);
// this file keeps only the ANSI-256 remapping that is specific to the
// limited-palette Apple Terminal path. contrastRatio/ensureContrast are
// re-exported for existing consumers (tests, /theme-info).
function parseHex(h: string): [number, number, number] | null {
const m = /^#?([0-9a-f]{6})$/i.exec(h)
if (!m) {
return null
}
const n = parseInt(m[1]!, 16)
return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]
}
function mix(a: string, b: string, t: number) {
const pa = parseHex(a)
const pb = parseHex(b)
if (!pa || !pb) {
return a
}
const lerp = (i: 0 | 1 | 2) => Math.round(pa[i] + (pb[i] - pa[i]) * t)
return '#' + ((1 << 24) | (lerp(0) << 16) | (lerp(1) << 8) | lerp(2)).toString(16).slice(1)
}
export { contrastRatio, ensureContrast } from './lib/color.js'
const XTERM_6_LEVELS = [0, 95, 135, 175, 215, 255] as const
const ANSI_LIGHT_MAX_LUMINANCE = 0.72
@ -121,15 +105,8 @@ function xtermEightBitRgb(colorNumber: number): [number, number, number] {
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)
}
const rgbLuminance = (red: number, green: number, blue: number): number =>
relativeLuminance(toHex([red, green, blue])) ?? 0
function rgbToHsl(red: number, green: number, blue: number): [number, number, number] {
const rn = red / 255
@ -183,7 +160,7 @@ function bestReadableAnsiColor(red: number, green: number, blue: number): number
for (let colorNumber = 16; colorNumber <= 255; colorNumber += 1) {
const [candidateRed, candidateGreen, candidateBlue] = xtermEightBitRgb(colorNumber)
const candidateLuminance = relativeLuminance(candidateRed, candidateGreen, candidateBlue)
const candidateLuminance = rgbLuminance(candidateRed, candidateGreen, candidateBlue)
if (candidateLuminance > ANSI_LIGHT_MAX_LUMINANCE) {
continue
@ -214,7 +191,7 @@ function bestReadableAnsiColor(red: number, green: number, blue: number): number
}
function normalizeAnsiForeground(color: string): string {
const rgb = parseHex(color)
const rgb = parseColor(color)
if (!rgb) {
return color
@ -224,7 +201,7 @@ function normalizeAnsiForeground(color: string): string {
const richRgb = xtermEightBitRgb(richAnsi)
const ansi =
relativeLuminance(richRgb[0], richRgb[1], richRgb[2]) > ANSI_LIGHT_MAX_LUMINANCE
rgbLuminance(richRgb[0], richRgb[1], richRgb[2]) > ANSI_LIGHT_MAX_LUMINANCE
? bestReadableAnsiColor(rgb[0], rgb[1], rgb[2])
: richAnsi
@ -251,101 +228,364 @@ const cleanPromptSymbol = (s: string | undefined, fallback: string) => {
return cleaned || fallback
}
// ── Seeds → palette ──────────────────────────────────────────────────
//
// A palette is BUILT, not enumerated: skins/base themes supply identity seeds
// (text, primary, accent, semantic hues) and every secondary tone is derived
// by the mix ladder against the background. This is the desktop token system
// (seeds → color-mix → tokens) in terminal form — "dim" is definitionally a
// derivative of the theme's own base colors and can never be incoherent.
export interface ThemeSeeds {
accent: string
/** Identity fill override: active list-row chip (derived when omitted). */
activeRow?: string
bg: string
border?: string
error: string
/** Identity tone override: muted/dim text (derived when omitted). */
muted?: string
ok: string
primary: string
prompt?: string
/** Identity fill override: text-selection highlight (derived when omitted). */
selection?: string
shellDollar: string
statusBad: string
statusCritical: string
statusGood: string
statusWarn: string
/** Identity fill override: panel/status surface (derived when omitted). */
surface?: string
text: string
warn: string
}
const DIFF_DARK = {
diffAdded: 'rgb(220,255,220)',
diffRemoved: 'rgb(255,220,220)',
diffAddedWord: 'rgb(36,138,61)',
diffRemovedWord: 'rgb(207,34,46)'
}
const DIFF_LIGHT = {
diffAdded: 'rgb(200,240,200)',
diffRemoved: 'rgb(240,200,200)',
diffAddedWord: 'rgb(27,94,32)',
diffRemovedWord: 'rgb(183,28,28)'
}
export function buildPalette(seeds: ThemeSeeds, isLight: boolean): ThemeColors {
const tones = deriveTones(seeds)
const surface = seeds.surface ?? tones.surface
const activeRow = seeds.activeRow ?? (seeds.surface ? mix(surface, seeds.accent, 0.22) : tones.activeRow)
const muted = seeds.muted ?? tones.muted
return {
primary: seeds.primary,
accent: seeds.accent,
border: seeds.border ?? tones.border,
text: seeds.text,
muted,
completionBg: surface,
completionCurrentBg: activeRow,
completionMetaBg: surface,
completionMetaCurrentBg: activeRow,
label: tones.label,
ok: seeds.ok,
error: seeds.error,
warn: seeds.warn,
prompt: seeds.prompt ?? seeds.text,
// sessionLabel/sessionBorder track the muted tone — "same role, same
// colour" by design (#11300).
sessionLabel: muted,
sessionBorder: muted,
statusBg: surface,
statusFg: tones.statusFg,
statusGood: seeds.statusGood,
statusWarn: seeds.statusWarn,
statusBad: seeds.statusBad,
statusCritical: seeds.statusCritical,
selectionBg: seeds.selection ?? tones.selection,
...(isLight ? DIFF_LIGHT : DIFF_DARK),
shellDollar: seeds.shellDollar
}
}
export const DARK_SEEDS: ThemeSeeds = {
accent: '#FFBF00',
// The classic Hermes navy surfaces are IDENTITY, not derivation drift —
// keep them as explicit fill seeds (the ladder derives them for skins
// that don't care).
activeRow: '#333355',
bg: '#101014',
border: '#CD7F32',
error: '#ef5350',
ok: '#4caf50',
primary: '#FFD700',
prompt: '#FFF8DC',
selection: '#3a3a55',
shellDollar: '#4dabf7',
statusBad: '#FF8C00',
statusCritical: '#FF6B6B',
statusGood: '#8FBC8F',
statusWarn: '#FFD700',
surface: '#1a1a2e',
text: '#FFF8DC',
warn: '#ffa726'
}
// Light-terminal seeds: darker golds/ambers that stay legible on white.
// The classic light-mode Hermes look was never hand-authored: for years the
// TUI emitted the DARK golds and hosts with xterm's minimumContrastRatio
// (Cursor defaults to 4.5) lifted them against white — hue and saturation
// kept, luminance clamped. These seeds are those exact lifts
// (liftForContrast(dark, '#ffffff', 4.5)), so hosts WITHOUT a contrast pass
// render the same thing Cursor always showed. Text/prompt stay ink — body
// copy historically rendered in the terminal's default near-black fg.
export const LIGHT_SEEDS: ThemeSeeds = {
accent: '#956E00',
bg: '#ffffff',
border: '#A56628',
error: '#C14240',
ok: '#367E39',
primary: '#867000',
prompt: '#2B2014',
shellDollar: '#377BB3',
statusBad: '#A65A00',
statusCritical: '#B94D4D',
statusGood: '#5C7A5C',
statusWarn: '#867000',
text: '#3D2F13',
warn: '#956115'
}
export const DARK_THEME: Theme = {
color: {
primary: '#FFD700',
accent: '#FFBF00',
border: '#CD7F32',
text: '#FFF8DC',
muted: '#CC9B1F',
// Bumped from the old `#B8860B` darkgoldenrod (~53% luminance) which
// read as barely-visible on dark terminals for long body text. The
// new value sits ~60% luminance — readable without losing the "muted /
// secondary" semantic. Field labels still use `label` (65%) which
// stays brighter so hierarchy holds.
completionBg: '#1a1a2e',
completionCurrentBg: '#333355',
completionMetaBg: '#1a1a2e',
completionMetaCurrentBg: '#333355',
label: '#DAA520',
ok: '#4caf50',
error: '#ef5350',
warn: '#ffa726',
prompt: '#FFF8DC',
// sessionLabel/sessionBorder intentionally track the `dim` value — they
// are "same role, same colour" by design. fromSkin's banner_dim fallback
// relies on this pairing (#11300).
sessionLabel: '#CC9B1F',
sessionBorder: '#CC9B1F',
statusBg: '#1a1a2e',
statusFg: '#C0C0C0',
statusGood: '#8FBC8F',
statusWarn: '#FFD700',
statusBad: '#FF8C00',
statusCritical: '#FF6B6B',
selectionBg: '#3a3a55',
diffAdded: 'rgb(220,255,220)',
diffRemoved: 'rgb(255,220,220)',
diffAddedWord: 'rgb(36,138,61)',
diffRemovedWord: 'rgb(207,34,46)',
shellDollar: '#4dabf7'
},
color: buildPalette(DARK_SEEDS, false),
brand: BRAND,
bannerLogo: '',
bannerHero: ''
}
// Light-terminal palette: darker golds/ambers that stay legible on white
// backgrounds. Same shape as DARK_THEME so `fromSkin` still layers on top
// cleanly (#11300).
export const LIGHT_THEME: Theme = {
color: {
primary: '#8B6914',
accent: '#A0651C',
border: '#7A4F1F',
text: '#3D2F13',
muted: '#7A5A0F',
completionBg: '#F5F5F5',
completionCurrentBg: mix('#F5F5F5', '#A0651C', 0.25),
completionMetaBg: '#F5F5F5',
completionMetaCurrentBg: mix('#F5F5F5', '#A0651C', 0.25),
label: '#7A5A0F',
ok: '#2E7D32',
error: '#C62828',
warn: '#E65100',
prompt: '#2B2014',
sessionLabel: '#7A5A0F',
sessionBorder: '#7A5A0F',
statusBg: '#F5F5F5',
statusFg: '#333333',
statusGood: '#2E7D32',
statusWarn: '#8B6914',
statusBad: '#D84315',
statusCritical: '#B71C1C',
selectionBg: '#D4E4F7',
diffAdded: 'rgb(200,240,200)',
diffRemoved: 'rgb(240,200,200)',
diffAddedWord: 'rgb(27,94,32)',
diffRemovedWord: 'rgb(183,28,28)',
shellDollar: '#1565C0'
},
color: buildPalette(LIGHT_SEEDS, true),
brand: BRAND,
bannerLogo: '',
bannerHero: ''
}
// ── Background-aware readability adaptation ─────────────────────────
//
// Mirrors the desktop app's theme contract (apps/desktop/src/themes): skins
// contribute accent IDENTITY; readability against the actual background is
// the theme engine's job, enforced in one place. Two guards, in the desktop's
// vocabulary:
//
// * `ensureContrast` — foreground-role colors are step-mixed toward the
// readable pole (black on light, white on dark) until they clear a
// minimum WCAG contrast ratio against the real (or assumed) background.
// Hue survives; washout doesn't.
// * Fill polarity — background-role colors (completion menu, status bar,
// selection) must match the terminal's polarity. Unlike the desktop, the
// TUI does not own its canvas — panels sit directly on the terminal's
// background — so a wrong-polarity fill (navy menu on a white terminal)
// falls back to the base palette even when the skin authored it.
// Display shim — the "rendering gotcha" layer, calibrated against the look
// the maintainers standardized on (pixel-sampled from the reference
// screenshot): the beloved cross-polarity rendering is the AUTHORED palette
// displayed RAW — slate's ~1.5:1 pastels on white read as deliberate airy
// hierarchy, not a bug. So the floors are barely-visible rescues only:
// * DISPLAY 1.45 sits just above slate-pastel territory (#c9d1d9 = 1.54,
// passes raw, byte-identical) but just below true invisibility
// (default's cream #FFF8DC = 1.08, gets rescued).
// * SEMANTIC 2.2 for alert colors (ok/error/warn/status) — they carry
// meaning and must never vanish.
// The lift itself is xterm.js's own multiplicative algorithm
// (liftForContrast), so on hosts that run their own minimumContrastRatio the
// two adjustments agree instead of fighting.
// Foreground floors are polarity-aware. On a DARK background the authored
// palette is already bright, so a modest floor only rescues the rare dark
// tone. On a LIGHT background — which in practice means a TRANSPARENT Cursor/
// terminal window compositing over a light editor, where xterm applies NO
// contrast lift of its own (there is no solid bg to measure against) — the
// beloved classic look is the authored palette rendered essentially RAW:
// vivid #FFD700 gold (~1.36:1), not a WCAG-darkened mustard. So the light
// floor is a near-invisible rescue only (catches cream #FFF8DC at 1.08 but
// leaves the golds untouched). Pixel-sampled target: #F5C242 (L61 S90),
// which the previous 1.45 floor crushed to #867000 (L26) — the reported mud.
const DISPLAY_MIN_CONTRAST = 1.45
const SEMANTIC_MIN_CONTRAST = 2.2
const LIGHT_DISPLAY_MIN_CONTRAST = 1.18
const LIGHT_SEMANTIC_MIN_CONTRAST = 1.6
const DISPLAY_FOREGROUNDS: readonly (keyof ThemeColors)[] = [
'primary',
'accent',
'text',
'label',
'prompt',
'statusFg',
'border',
'muted',
'sessionLabel',
'sessionBorder',
'shellDollar'
]
const SEMANTIC_FOREGROUNDS: readonly (keyof ThemeColors)[] = [
'ok',
'error',
'warn',
'statusGood',
'statusWarn',
'statusBad',
'statusCritical'
]
const ADAPTIVE_BACKGROUNDS: readonly (keyof ThemeColors)[] = [
'completionBg',
'completionCurrentBg',
'completionMetaBg',
'completionMetaCurrentBg',
'statusBg',
'selectionBg'
]
// Fill polarity limits: on light terminals a fill must stay light, and vice
// versa — there is no readable middle for a panel fill on the wrong pole.
const LIGHT_BG_MIN_LUMINANCE = 0.4
const DARK_BG_MAX_LUMINANCE = 0.35
function adaptColorsToBackground(colors: ThemeColors, isLight: boolean, base: ThemeColors, bg: string): ThemeColors {
const out = { ...colors }
const displayFloor = isLight ? LIGHT_DISPLAY_MIN_CONTRAST : DISPLAY_MIN_CONTRAST
const semanticFloor = isLight ? LIGHT_SEMANTIC_MIN_CONTRAST : SEMANTIC_MIN_CONTRAST
for (const key of DISPLAY_FOREGROUNDS) {
out[key] = liftForContrast(out[key], bg, displayFloor)
}
for (const key of SEMANTIC_FOREGROUNDS) {
out[key] = liftForContrast(out[key], bg, semanticFloor)
}
for (const key of ADAPTIVE_BACKGROUNDS) {
const luminance = relativeLuminance(out[key])
if (luminance === null) {
continue
}
if (isLight ? luminance < LIGHT_BG_MIN_LUMINANCE : luminance > DARK_BG_MAX_LUMINANCE) {
out[key] = base[key]
}
}
return out
}
/** The background hex adaptation measures contrast against: the OSC-11
* answer when known (cached in HERMES_TUI_BACKGROUND), else the mode's
* assumed pole. */
function referenceBackground(isLight: boolean, env: NodeJS.ProcessEnv = process.env): string {
const cached = (env.HERMES_TUI_BACKGROUND ?? '').trim()
if (cached && backgroundLuminance(cached) !== null) {
return cached.startsWith('#') ? cached : `#${cached}`
}
return isLight ? '#ffffff' : '#101014'
}
// ── Derived tone ladder (the desktop color-mix system) ──────────────
//
// A theme is a handful of SEEDS (text, primary, accent, border, status hues);
// every secondary tone — muted text, labels, surfaces, selection chips — is a
// color-mix derivative of those seeds against the real terminal background,
// exactly like the desktop's `--theme-*` seeds → `--ui-*` color-mix ladder in
// apps/desktop/src/styles.css. Skins therefore cannot ship an incoherent
// "dim": if they don't author a tone, it is DERIVED from their own identity,
// never inherited from another skin's palette.
//
// Mix knobs are the single source of truth for tone hierarchy. (The classic
// prompt_toolkit CLI still reads the built-in skins' complete authored
// palettes; those were generated with the same math.)
export interface ThemeTones {
/** Secondary/dim text: receded accent (dark) / primary-ink blend (light). */
muted: string
/** Field labels: one step brighter than muted, same family. */
label: string
/** Status-bar default text: the gray of slightly-receded text. */
statusFg: string
/** Raised panel fill: background nudged toward the (softened) accent. */
surface: string
/** Active list-row chip: surface tinted with accent. */
activeRow: string
/** Text-selection highlight: bg tinted with the theme's blue (light) or accent (dark). */
selection: string
/** Border fallback: accent receded toward the background. */
border: string
}
/**
* The fitted tone ladder. Knobs are REVERSE-ENGINEERED from the original
* hand-tuned palettes (grid-search over mix/desaturate formula families
* against the pre-refactor literals + every authored skin palette; see the
* "reproduces the original hand-tuned tones" test for the contract):
*
* dark muted #CC9B1F desaturate(mix(accent, bg, .19), .16) (err 3)
* dark label #DAA520 desaturate(mix(accent, bg, .13), .16) (err 3)
* dark status #C0C0C0 = grayOf(mix(text, bg, .24)) (err 0)
* light muted #946C08 desaturate(accent, .05) (err 2)
* light label #8E6B13 desaturate(mix(accent, text, .03), .15) (err 2)
* light status #6F6F6F = grayOf(mix(text, bg, .30)) (err 1)
* light surface #F5F5F5 bg + softened accent (err 5)
* light chip #E0D1BF = mix(surface, accent, .25) (err 8)
* light selection #D4E4F7 mix(bg, shellDollar, .20) (err 7)
*
* The light targets are the LIFT CANON: liftForContrast(dark literal,
* white, 4.5) what xterm's minimumContrastRatio showed on light hosts
* for years not hand-picked browns (those read as desaturated mud).
*
* The classic dark navy fills (#1a1a2e/#333355/#3a3a55) are IRREDUCIBLE from
* gold seeds the search bottoms out at gray, err 1017 so they remain
* explicit identity seeds on DARK_SEEDS rather than pretending to be math.
*/
export function deriveTones(seeds: {
accent: string
bg: string
primary: string
shellDollar?: string
text: string
}): ThemeTones {
const { accent, bg, text } = seeds
const isLight = (relativeLuminance(bg) ?? 0) > 0.5
// Fill tint keeps most of the accent's chroma — a heavier desaturate here
// read as washed-out ("a little too desat") next to authored fills.
const surface = mix(bg, desaturate(accent, 0.15), isLight ? 0.045 : 0.09)
return {
// Light knobs are fitted to the lift canon (xterm minimumContrastRatio
// 4.5 of the classic dark golds against white — see LIGHT_SEEDS), not
// to ink blends: muted #946C08 ≈ desat(accent .05), label #8E6B13 ≈
// desat(mix(accent, text, .03), .15), statusFg #6F6F6F ≈ gray 30% lift.
muted: isLight ? desaturate(accent, 0.05) : desaturate(mix(accent, bg, 0.19), 0.16),
label: isLight ? desaturate(mix(accent, text, 0.03), 0.15) : desaturate(mix(accent, bg, 0.13), 0.16),
statusFg: grayOf(mix(text, bg, isLight ? 0.3 : 0.24)),
surface,
activeRow: mix(surface, accent, 0.25),
selection:
isLight && seeds.shellDollar ? mix(bg, seeds.shellDollar, 0.2) : mix(surface, accent, 0.28),
border: mix(accent, bg, 0.25)
}
}
const TRUE_RE = /^(?:1|true|yes|on)$/
const FALSE_RE = /^(?:0|false|no|off)$/
@ -508,6 +748,19 @@ export const DEFAULT_THEME: Theme = normalizeThemeForAnsiLightTerminal(
DEFAULT_LIGHT_MODE
)
/**
* The skinless theme for the CURRENT light-mode signals. Unlike the frozen
* module-load DEFAULT_THEME, this re-reads the environment so it picks up
* the OSC-11 background answer cached into HERMES_TUI_BACKGROUND after
* startup. Used when the terminal background arrives before (or without) a
* gateway skin.
*/
export function defaultThemeForCurrentBackground(env: NodeJS.ProcessEnv = process.env): Theme {
const isLight = detectLightMode(env)
return normalizeThemeForAnsiLightTerminal(isLight ? LIGHT_THEME : DARK_THEME, env, isLight)
}
// ── Skin → Theme ─────────────────────────────────────────────────────
export function fromSkin(
@ -518,61 +771,85 @@ export function fromSkin(
toolPrefix = '',
helpHeader = ''
): Theme {
const d = DEFAULT_THEME
// Live detection (not the module-load snapshot): by the time the gateway
// skin arrives, the OSC-11 background probe has usually answered and cached
// itself into HERMES_TUI_BACKGROUND. See #applySkin / syncThemeToTerminalBackground.
const isLight = detectLightMode()
const bg = referenceBackground(isLight)
const base = isLight ? LIGHT_SEEDS : DARK_SEEDS
const d = isLight ? LIGHT_THEME : DARK_THEME
const c = (k: string) => colors[k]
const hasSkinColors = Object.keys(colors).length > 0
const accent = c('ui_accent') ?? c('banner_accent') ?? d.color.accent
const bannerAccent = c('banner_accent') ?? c('banner_title') ?? d.color.accent
const muted = c('banner_dim') ?? d.color.muted
const completionBg = c('completion_menu_bg') ?? d.color.completionBg
// 1. Seeds: the skin's identity. Anything it doesn't define comes from the
// base seeds for this polarity. The base's IDENTITY FILLS (Hermes navy
// surfaces, gold muted) only carry over for the skinless default — a
// skin with its own identity derives its fills from its own seeds.
const identityFills: Partial<ThemeSeeds> = hasSkinColors
? {}
: { activeRow: base.activeRow, muted: base.muted, selection: base.selection, surface: base.surface }
const completionCurrentBg =
const seeds: ThemeSeeds = {
...identityFills,
accent: c('ui_accent') ?? c('banner_accent') ?? base.accent,
bg,
border: c('ui_border') ?? c('banner_border') ?? base.border,
error: c('ui_error') ?? base.error,
ok: c('ui_ok') ?? base.ok,
primary: c('ui_primary') ?? c('banner_title') ?? base.primary,
prompt: c('prompt') ?? c('banner_text') ?? base.prompt,
shellDollar: c('shell_dollar') ?? base.shellDollar,
statusBad: c('status_bar_bad') ?? base.statusBad,
statusCritical: c('status_bar_critical') ?? c('ui_error') ?? base.statusCritical,
statusGood: c('status_bar_good') ?? c('ui_ok') ?? base.statusGood,
statusWarn: c('status_bar_warn') ?? c('ui_warn') ?? base.statusWarn,
text: c('ui_text') ?? c('banner_text') ?? base.text,
warn: c('ui_warn') ?? base.warn
}
// 2. Derive: every secondary tone is a color-mix of the seeds against the
// REAL background — dim is a derivative of the skin's own identity.
const derived = buildPalette(seeds, isLight)
// 3. Authored tone overrides: a skin may still hand-tune any tone; the
// derived ladder is the default, not a cage. Chip/selection re-derive
// from the FINAL surface so dependents stay coherent with overrides.
const surface = c('completion_menu_bg') ?? derived.completionBg
// Re-mix the chip only when the skin authored its own surface; otherwise
// the derived value already carries the identity seeds (e.g. Hermes navy).
const activeRow =
c('completion_menu_current_bg') ??
(hasSkinColors ? mix(completionBg, bannerAccent, 0.25) : d.color.completionCurrentBg)
(c('completion_menu_bg') ? mix(surface, seeds.accent, 0.22) : derived.completionCurrentBg)
const completionMetaBg = c('completion_menu_meta_bg') ?? completionBg
const completionMetaCurrentBg = c('completion_menu_meta_current_bg') ?? completionCurrentBg
const assembled: ThemeColors = {
...derived,
muted: c('banner_dim') ?? derived.muted,
label: c('ui_label') ?? derived.label,
completionBg: surface,
completionCurrentBg: activeRow,
completionMetaBg: c('completion_menu_meta_bg') ?? surface,
completionMetaCurrentBg: c('completion_menu_meta_current_bg') ?? activeRow,
sessionLabel: c('session_label') ?? c('banner_dim') ?? derived.sessionLabel,
sessionBorder: c('session_border') ?? c('banner_dim') ?? derived.sessionBorder,
statusBg: c('status_bar_bg') ?? surface,
statusFg: c('status_bar_text') ?? derived.statusFg,
selectionBg: c('selection_bg') ?? c('completion_menu_current_bg') ?? derived.selectionBg
}
// 4. Guard: contrast floors against the real background + fill polarity.
// Wrong-polarity fills fall back to the DERIVED value, which is
// polarity-correct by construction (mixed from the background itself).
// ANSI-limited light Apple Terminal keeps its bespoke ansi256
// normalization (below) instead.
const adapted = shouldNormalizeAnsiLightTheme(process.env, isLight)
? assembled
: adaptColorsToBackground(assembled, isLight, derived, bg)
return normalizeThemeForAnsiLightTerminal(
{
color: {
primary: c('ui_primary') ?? c('banner_title') ?? d.color.primary,
accent,
border: c('ui_border') ?? c('banner_border') ?? d.color.border,
text: c('ui_text') ?? c('banner_text') ?? d.color.text,
muted,
completionBg,
completionCurrentBg,
completionMetaBg,
completionMetaCurrentBg,
label: c('ui_label') ?? d.color.label,
ok: c('ui_ok') ?? d.color.ok,
error: c('ui_error') ?? d.color.error,
warn: c('ui_warn') ?? d.color.warn,
prompt: c('prompt') ?? c('banner_text') ?? d.color.prompt,
sessionLabel: c('session_label') ?? muted,
sessionBorder: c('session_border') ?? muted,
statusBg: d.color.statusBg,
statusFg: d.color.statusFg,
statusGood: c('ui_ok') ?? d.color.statusGood,
statusWarn: c('ui_warn') ?? d.color.statusWarn,
statusBad: d.color.statusBad,
statusCritical: d.color.statusCritical,
selectionBg:
c('selection_bg') ??
c('completion_menu_current_bg') ??
(hasSkinColors ? completionCurrentBg : d.color.selectionBg),
diffAdded: d.color.diffAdded,
diffRemoved: d.color.diffRemoved,
diffAddedWord: d.color.diffAddedWord,
diffRemovedWord: d.color.diffRemovedWord,
shellDollar: c('shell_dollar') ?? d.color.shellDollar
},
color: adapted,
brand: {
name: branding.agent_name ?? d.brand.name,
@ -588,6 +865,6 @@ export function fromSkin(
bannerHero
},
process.env,
DEFAULT_LIGHT_MODE
isLight
)
}

View file

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