mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +00:00
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:
commit
e0e15436ee
57 changed files with 5905 additions and 454 deletions
|
|
@ -48,6 +48,16 @@ All fields are optional. Missing values inherit from the ``default`` skin.
|
|||
completion_menu_meta_bg: "#1a1a2e" # Completion meta column background
|
||||
completion_menu_meta_current_bg: "#333355" # Active completion meta background
|
||||
|
||||
# Optional paired palette for the opposite terminal polarity (mirrors the
|
||||
# desktop app's colors/darkColors pairing). If `colors` above is authored
|
||||
# for dark terminals, `light_colors` supplies the hand-tuned light-terminal
|
||||
# variant (same keys); light-authored skins supply `dark_colors` instead.
|
||||
# Without a paired block, the TUI adapts `colors` automatically
|
||||
# (contrast-clamped foregrounds, polarity-corrected fills).
|
||||
light_colors:
|
||||
banner_title: "#8B6914"
|
||||
# ... same keys as `colors` ...
|
||||
|
||||
# Spinner: customize the animated spinner during API calls
|
||||
spinner:
|
||||
waiting_faces: # Faces shown while waiting for API
|
||||
|
|
@ -132,6 +142,14 @@ class SkinConfig:
|
|||
name: str
|
||||
description: str = ""
|
||||
colors: Dict[str, str] = field(default_factory=dict)
|
||||
# Paired palettes for terminals whose background polarity differs from the
|
||||
# one `colors` was authored against (mirrors the desktop app's
|
||||
# colors/darkColors pairing). A consumer that knows the terminal is light
|
||||
# prefers `light_colors` (falling back to `colors`), and vice versa for
|
||||
# `dark_colors`. Both merge over the default skin's matching block, so
|
||||
# partial user skins still resolve to a complete palette.
|
||||
light_colors: Dict[str, str] = field(default_factory=dict)
|
||||
dark_colors: Dict[str, str] = field(default_factory=dict)
|
||||
spinner: Dict[str, Any] = field(default_factory=dict)
|
||||
branding: Dict[str, str] = field(default_factory=dict)
|
||||
tool_prefix: str = "┊"
|
||||
|
|
@ -165,6 +183,8 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
|
|||
"default": {
|
||||
"name": "default",
|
||||
"description": "Classic Hermes — gold and kawaii",
|
||||
# Dark-authored. Values match the TUI's DARK_THEME so the classic CLI
|
||||
# and the TUI render the same Hermes gold.
|
||||
"colors": {
|
||||
"banner_border": "#CD7F32",
|
||||
"banner_title": "#FFD700",
|
||||
|
|
@ -180,8 +200,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", "┊")),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
223
tests/hermes_cli/test_skin_palettes.py
Normal file
223
tests/hermes_cli/test_skin_palettes.py
Normal 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})"
|
||||
)
|
||||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { INITIAL_STATE, parseMultipleKeypresses } from './parse-keypress.js'
|
||||
import { oscColor, TerminalQuerier } from './terminal-querier.js'
|
||||
import { parseOscColor } from './terminal.js'
|
||||
|
||||
// End-to-end (minus the PTY): a terminal's OSC 11 reply arriving on stdin
|
||||
// must tokenize as a response, match the pending oscColor(11) query, and
|
||||
// parse to a hex background. This is the exact chain App.tsx relies on for
|
||||
// light/dark detection — if any segment drops the reply, detection dies
|
||||
// silently because the querier never times out.
|
||||
|
||||
const parseWire = (raw: string) => {
|
||||
const [keys] = parseMultipleKeypresses({ ...INITIAL_STATE }, raw)
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
const drainResponses = (raw: string) => parseWire(raw).filter(k => k.kind === 'response')
|
||||
|
||||
describe('OSC 11 reply chain', () => {
|
||||
it.each([
|
||||
['BEL-terminated', '\x1b]11;rgb:ffff/ffff/ffff\x07', '#ffffff'],
|
||||
['ST-terminated', '\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\', '#1e1e2e']
|
||||
])('resolves a pending query from a %s reply', async (_label, wire, expectedHex) => {
|
||||
const writes: string[] = []
|
||||
const stdout = { write: (s: string) => (writes.push(s), true) } as unknown as NodeJS.WriteStream
|
||||
const querier = new TerminalQuerier(stdout)
|
||||
|
||||
const pending = querier.send(oscColor(11))
|
||||
|
||||
expect(writes.join('')).toContain('\x1b]11;?')
|
||||
|
||||
const responses = drainResponses(wire)
|
||||
|
||||
expect(responses).toHaveLength(1)
|
||||
|
||||
for (const r of responses) {
|
||||
if (r.kind === 'response') {
|
||||
querier.onResponse(r.response)
|
||||
}
|
||||
}
|
||||
|
||||
const reply = await pending
|
||||
|
||||
expect(reply?.type).toBe('osc')
|
||||
|
||||
if (reply?.type === 'osc') {
|
||||
expect(reply.code).toBe(11)
|
||||
expect(parseOscColor(reply.data)).toBe(expectedHex)
|
||||
}
|
||||
})
|
||||
|
||||
it('reply interleaved with keystrokes still resolves', async () => {
|
||||
const stdout = { write: () => true } as unknown as NodeJS.WriteStream
|
||||
const querier = new TerminalQuerier(stdout)
|
||||
const pending = querier.send(oscColor(11))
|
||||
|
||||
const keys = parseWire('a\x1b]11;rgb:0000/0000/0000\x07b')
|
||||
|
||||
for (const k of keys) {
|
||||
if (k.kind === 'response') {
|
||||
querier.onResponse(k.response)
|
||||
}
|
||||
}
|
||||
|
||||
const reply = await pending
|
||||
|
||||
expect(reply?.type).toBe('osc')
|
||||
|
||||
// The surrounding keystrokes survive as normal input.
|
||||
const chars = keys.filter(k => k.kind !== 'response')
|
||||
|
||||
expect(chars.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
309
ui-tui/scripts/visual/render.tsx
Normal file
309
ui-tui/scripts/visual/render.tsx
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
|
||||
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 "fix the lint errors"</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)
|
||||
23
ui-tui/scripts/visual/shot.mjs
Normal file
23
ui-tui/scripts/visual/shot.mjs
Normal 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()
|
||||
})
|
||||
|
|
@ -26,6 +26,7 @@ import {
|
|||
sessionRowKindAt,
|
||||
sessionsCountLabel
|
||||
} from '../components/activeSessionSwitcher.js'
|
||||
import { listRowStyle } from '../components/overlayPrimitives.js'
|
||||
import type { SessionActiveItem } from '../gatewayTypes.js'
|
||||
import type { SessionListItem } from '../gatewayTypes.js'
|
||||
import { DEFAULT_THEME } from '../theme.js'
|
||||
|
|
@ -75,13 +76,19 @@ describe('session orchestrator helpers', () => {
|
|||
expect(newSessionMarkerColor(DEFAULT_THEME, true)).toBe(DEFAULT_THEME.color.text)
|
||||
})
|
||||
|
||||
it('uses a readable selected row style instead of accent-on-accent inverse text', () => {
|
||||
it('uses the shared list-row primitive for the selected row (same as completions)', () => {
|
||||
const style = selectedSessionRowStyle(DEFAULT_THEME)
|
||||
const shared = listRowStyle(DEFAULT_THEME, true)
|
||||
|
||||
expect(style.backgroundColor).toBe(DEFAULT_THEME.color.selectionBg)
|
||||
expect(style.color).toBe(DEFAULT_THEME.color.text)
|
||||
// One source of truth: the session switcher and the completions popover
|
||||
// cannot disagree about what "selected" looks like.
|
||||
expect(style.backgroundColor).toBe(shared.backgroundColor)
|
||||
expect(style.color).toBe(shared.color)
|
||||
// Readability contract survives: never accent-on-accent inverse.
|
||||
expect(style.backgroundColor).not.toBe(DEFAULT_THEME.color.accent)
|
||||
expect(style.color).not.toBe(DEFAULT_THEME.color.accent)
|
||||
// Inactive rows paint nothing — the terminal's canvas is the row bg.
|
||||
expect(listRowStyle(DEFAULT_THEME, false)).toEqual({})
|
||||
})
|
||||
|
||||
it('turns model picker values into session-scoped draft model args', () => {
|
||||
|
|
|
|||
|
|
@ -723,6 +723,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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
48
ui-tui/src/__tests__/loaders.test.ts
Normal file
48
ui-tui/src/__tests__/loaders.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
22
ui-tui/src/__tests__/overlayPrimitives.test.ts
Normal file
22
ui-tui/src/__tests__/overlayPrimitives.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
246
ui-tui/src/__tests__/widgetGrid.test.ts
Normal file
246
ui-tui/src/__tests__/widgetGrid.test.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
143
ui-tui/src/__tests__/widgetGridComponent.test.tsx
Normal file
143
ui-tui/src/__tests__/widgetGridComponent.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
393
ui-tui/src/components/gridStreamsDemo.tsx
Normal file
393
ui-tui/src/components/gridStreamsDemo.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
})
|
||||
316
ui-tui/src/components/gridTestOverlay.tsx
Normal file
316
ui-tui/src/components/gridTestOverlay.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
112
ui-tui/src/components/loaders.tsx
Normal file
112
ui-tui/src/components/loaders.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
141
ui-tui/src/components/overlay.tsx
Normal file
141
ui-tui/src/components/overlay.tsx
Normal 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]]
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
272
ui-tui/src/components/widgetGrid.tsx
Normal file
272
ui-tui/src/components/widgetGrid.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
|
|
@ -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 ===`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
66
ui-tui/src/lib/color.test.ts
Normal file
66
ui-tui/src/lib/color.test.ts
Normal 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
324
ui-tui/src/lib/color.ts
Normal 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 (1–21). 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
128
ui-tui/src/lib/themeBoot.ts
Normal 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
|
||||
505
ui-tui/src/lib/widgetGrid.ts
Normal file
505
ui-tui/src/lib/widgetGrid.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 10–17 — 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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
5
ui-tui/src/types/hermes-ink.d.ts
vendored
5
ui-tui/src/types/hermes-ink.d.ts
vendored
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue