Compare commits
22 Commits
bb/tui-rel
...
bb/theme
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d30e9b9fb5 | ||
|
|
1d4218be56 | ||
|
|
8c892c1453 | ||
|
|
6e9691ff12 | ||
|
|
10ad7006b6 | ||
|
|
f542d17b00 | ||
|
|
d7ae8dfd0a | ||
|
|
ce2cc7302e | ||
|
|
afb20a1d67 | ||
|
|
cd7150a195 | ||
|
|
adef1f33ab | ||
|
|
fe295f9836 | ||
|
|
fd943461ca | ||
|
|
9f004b6d94 | ||
|
|
188eaa57c4 | ||
|
|
6b09df39be | ||
|
|
a9efa46b69 | ||
|
|
b2f936fd37 | ||
|
|
ec11aa64ee | ||
|
|
7d81d76366 | ||
|
|
258efb2575 | ||
|
|
1e326c686d |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -70,3 +70,4 @@ mini-swe-agent/
|
||||
result
|
||||
website/static/api/skills-index.json
|
||||
models-dev-upstream/
|
||||
.venv
|
||||
|
||||
@@ -494,7 +494,7 @@ branding:
|
||||
agent_name: "My Agent"
|
||||
welcome: "Welcome message"
|
||||
response_label: " ⚔ Agent "
|
||||
prompt_symbol: "⚔ ❯ "
|
||||
prompt_symbol: "⚔"
|
||||
|
||||
tool_prefix: "╎" # Tool output line prefix
|
||||
```
|
||||
|
||||
@@ -184,11 +184,59 @@ _PREFIX_RE = re.compile(
|
||||
)
|
||||
|
||||
|
||||
def mask_secret(
|
||||
value: str,
|
||||
*,
|
||||
head: int = 4,
|
||||
tail: int = 4,
|
||||
floor: int = 12,
|
||||
placeholder: str = "***",
|
||||
empty: str = "",
|
||||
) -> str:
|
||||
"""Mask a secret for display, preserving ``head`` and ``tail`` characters.
|
||||
|
||||
Canonical helper for display-time redaction across Hermes — used by
|
||||
``hermes config``, ``hermes status``, ``hermes dump``, and anywhere
|
||||
a secret needs to be shown truncated for debuggability while still
|
||||
keeping the bulk hidden.
|
||||
|
||||
Args:
|
||||
value: The secret to mask. ``None``/empty returns ``empty``.
|
||||
head: Leading characters to preserve. Default 4.
|
||||
tail: Trailing characters to preserve. Default 4.
|
||||
floor: Values shorter than ``head + tail + floor_margin`` are
|
||||
fully masked (returns ``placeholder``). Default 12 —
|
||||
matches the existing config/status/dump convention.
|
||||
placeholder: Value returned for too-short inputs. Default ``"***"``.
|
||||
empty: Value returned when ``value`` is falsy (None, ""). The
|
||||
caller can override this to e.g. ``color("(not set)",
|
||||
Colors.DIM)`` for user-facing display.
|
||||
|
||||
Examples:
|
||||
>>> mask_secret("sk-proj-abcdef1234567890")
|
||||
'sk-p...7890'
|
||||
>>> mask_secret("short") # fully masked
|
||||
'***'
|
||||
>>> mask_secret("") # empty default
|
||||
''
|
||||
>>> mask_secret("", empty="(not set)") # empty override
|
||||
'(not set)'
|
||||
>>> mask_secret("long-token", head=6, tail=4, floor=18)
|
||||
'***'
|
||||
"""
|
||||
if not value:
|
||||
return empty
|
||||
if len(value) < floor:
|
||||
return placeholder
|
||||
return f"{value[:head]}...{value[-tail:]}"
|
||||
|
||||
|
||||
def _mask_token(token: str) -> str:
|
||||
"""Mask a token, preserving prefix for long tokens."""
|
||||
if len(token) < 18:
|
||||
"""Mask a log token — conservative 18-char floor, preserves 6 prefix / 4 suffix."""
|
||||
# Empty input: historically this returned "***" rather than "". Preserve.
|
||||
if not token:
|
||||
return "***"
|
||||
return f"{token[:6]}...{token[-4:]}"
|
||||
return mask_secret(token, head=6, tail=4, floor=18)
|
||||
|
||||
|
||||
def _redact_query_string(query: str) -> str:
|
||||
|
||||
@@ -927,7 +927,7 @@ display:
|
||||
# agent_name: "My Agent" # Banner title and branding
|
||||
# welcome: "Welcome message" # Shown at CLI startup
|
||||
# response_label: " ⚔ Agent " # Response box header label
|
||||
# prompt_symbol: "⚔ ❯ " # Prompt symbol
|
||||
# prompt_symbol: "⚔" # Prompt symbol (bare token; renderers add trailing space)
|
||||
# tool_prefix: "╎" # Tool output line prefix (default: ┊)
|
||||
#
|
||||
skin: default
|
||||
|
||||
@@ -128,6 +128,9 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
subcommands=("normal", "fast", "status", "on", "off")),
|
||||
CommandDef("skin", "Show or change the display skin/theme", "Configuration",
|
||||
cli_only=True, args_hint="[name]"),
|
||||
CommandDef("indicator", "Pick the TUI busy-indicator style", "Configuration",
|
||||
cli_only=True, args_hint="[kaomoji|emoji|unicode|ascii]",
|
||||
subcommands=("kaomoji", "emoji", "unicode", "ascii")),
|
||||
CommandDef("voice", "Toggle voice mode", "Configuration",
|
||||
args_hint="[on|off|tts|status]", subcommands=("on", "off", "tts", "status")),
|
||||
CommandDef("busy", "Control what Enter does while Hermes is working", "Configuration",
|
||||
|
||||
@@ -715,6 +715,9 @@ DEFAULT_CONFIG = {
|
||||
"inline_diffs": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)
|
||||
"show_cost": False, # Show $ cost in the status bar (off by default)
|
||||
"skin": "default",
|
||||
# TUI busy indicator style: kaomoji (default), emoji, unicode (braille
|
||||
# spinner), or ascii. Live-swappable via `/indicator <style>`.
|
||||
"tui_status_indicator": "kaomoji",
|
||||
"user_message_preview": { # CLI: how many submitted user-message lines to echo back in scrollback
|
||||
"first_lines": 2,
|
||||
"last_lines": 2,
|
||||
@@ -4010,12 +4013,13 @@ def get_env_value(key: str) -> Optional[str]:
|
||||
# =============================================================================
|
||||
|
||||
def redact_key(key: str) -> str:
|
||||
"""Redact an API key for display."""
|
||||
if not key:
|
||||
return color("(not set)", Colors.DIM)
|
||||
if len(key) < 12:
|
||||
return "***"
|
||||
return key[:4] + "..." + key[-4:]
|
||||
"""Redact an API key for display.
|
||||
|
||||
Thin wrapper over :func:`agent.redact.mask_secret` — preserves the
|
||||
"(not set)" placeholder in dim color for the empty case.
|
||||
"""
|
||||
from agent.redact import mask_secret
|
||||
return mask_secret(key, empty=color("(not set)", Colors.DIM))
|
||||
|
||||
|
||||
def show_config():
|
||||
|
||||
@@ -293,15 +293,23 @@ def run_doctor(args):
|
||||
|
||||
known_providers: set = set()
|
||||
try:
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||
from hermes_cli.auth import (
|
||||
PROVIDER_REGISTRY,
|
||||
resolve_provider as _resolve_auth_provider,
|
||||
)
|
||||
known_providers = set(PROVIDER_REGISTRY.keys()) | {"openrouter", "custom", "auto"}
|
||||
except Exception:
|
||||
_resolve_auth_provider = None
|
||||
pass
|
||||
try:
|
||||
from hermes_cli.config import get_compatible_custom_providers as _compatible_custom_providers
|
||||
from hermes_cli.providers import resolve_provider_full as _resolve_provider_full
|
||||
from hermes_cli.providers import (
|
||||
normalize_provider as _normalize_catalog_provider,
|
||||
resolve_provider_full as _resolve_provider_full,
|
||||
)
|
||||
except Exception:
|
||||
_compatible_custom_providers = None
|
||||
_normalize_catalog_provider = None
|
||||
_resolve_provider_full = None
|
||||
|
||||
custom_providers = []
|
||||
@@ -321,17 +329,43 @@ def run_doctor(args):
|
||||
if name:
|
||||
known_providers.add("custom:" + name.lower().replace(" ", "-"))
|
||||
|
||||
canonical_provider = provider
|
||||
valid_provider_ids = set(known_providers)
|
||||
provider_ids_to_accept = {provider} if provider else set()
|
||||
if _normalize_catalog_provider is not None:
|
||||
for known_provider in known_providers:
|
||||
try:
|
||||
valid_provider_ids.add(_normalize_catalog_provider(known_provider))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
runtime_provider = provider
|
||||
if (
|
||||
provider
|
||||
and _resolve_auth_provider is not None
|
||||
and provider not in ("auto", "custom")
|
||||
):
|
||||
try:
|
||||
runtime_provider = _resolve_auth_provider(provider)
|
||||
provider_ids_to_accept.add(runtime_provider)
|
||||
except Exception:
|
||||
runtime_provider = provider
|
||||
|
||||
catalog_provider = provider
|
||||
if (
|
||||
provider
|
||||
and _resolve_provider_full is not None
|
||||
and provider not in ("auto", "custom")
|
||||
):
|
||||
provider_def = _resolve_provider_full(provider, user_providers, custom_providers)
|
||||
canonical_provider = provider_def.id if provider_def is not None else None
|
||||
catalog_provider = provider_def.id if provider_def is not None else None
|
||||
if catalog_provider is not None:
|
||||
provider_ids_to_accept.add(catalog_provider)
|
||||
|
||||
if provider and provider != "auto":
|
||||
if canonical_provider is None or (known_providers and canonical_provider not in known_providers):
|
||||
if catalog_provider is None or (
|
||||
known_providers
|
||||
and not (provider_ids_to_accept & valid_provider_ids)
|
||||
):
|
||||
known_list = ", ".join(sorted(known_providers)) if known_providers else "(unavailable)"
|
||||
check_fail(
|
||||
f"model.provider '{provider_raw}' is not a recognised provider",
|
||||
@@ -344,7 +378,24 @@ def run_doctor(args):
|
||||
)
|
||||
|
||||
# Warn if model is set to a provider-prefixed name on a provider that doesn't use them
|
||||
if default_model and "/" in default_model and canonical_provider and canonical_provider not in ("openrouter", "custom", "auto", "ai-gateway", "kilocode", "opencode-zen", "huggingface", "nous", "lmstudio"):
|
||||
provider_for_policy = runtime_provider or catalog_provider
|
||||
providers_accepting_vendor_slugs = {
|
||||
"openrouter",
|
||||
"custom",
|
||||
"auto",
|
||||
"ai-gateway",
|
||||
"kilocode",
|
||||
"opencode-zen",
|
||||
"huggingface",
|
||||
"lmstudio",
|
||||
"nous",
|
||||
}
|
||||
if (
|
||||
default_model
|
||||
and "/" in default_model
|
||||
and provider_for_policy
|
||||
and provider_for_policy not in providers_accepting_vendor_slugs
|
||||
):
|
||||
check_warn(
|
||||
f"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'",
|
||||
"(vendor-prefixed slugs belong to aggregators like openrouter)",
|
||||
@@ -360,20 +411,24 @@ def run_doctor(args):
|
||||
# own env-var checks elsewhere in doctor, and get_auth_status()
|
||||
# returns a bare {logged_in: False} for anything it doesn't
|
||||
# explicitly dispatch, which would produce false positives.
|
||||
if canonical_provider and canonical_provider not in ("auto", "custom", "openrouter"):
|
||||
if runtime_provider and runtime_provider not in ("auto", "custom", "openrouter"):
|
||||
try:
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status
|
||||
pconfig = PROVIDER_REGISTRY.get(canonical_provider)
|
||||
pconfig = PROVIDER_REGISTRY.get(runtime_provider)
|
||||
if pconfig and getattr(pconfig, "auth_type", "") == "api_key":
|
||||
status = get_auth_status(canonical_provider) or {}
|
||||
configured = bool(status.get("configured") or status.get("logged_in") or status.get("api_key"))
|
||||
status = get_auth_status(runtime_provider) or {}
|
||||
configured = bool(
|
||||
status.get("configured")
|
||||
or status.get("logged_in")
|
||||
or status.get("api_key")
|
||||
)
|
||||
if not configured:
|
||||
check_fail(
|
||||
f"model.provider '{canonical_provider}' is set but no API key is configured",
|
||||
f"model.provider '{runtime_provider}' is set but no API key is configured",
|
||||
"(check ~/.hermes/.env or run 'hermes setup')",
|
||||
)
|
||||
issues.append(
|
||||
f"No credentials found for provider '{canonical_provider}'. "
|
||||
f"No credentials found for provider '{runtime_provider}'. "
|
||||
f"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, "
|
||||
f"or switch providers with 'hermes config set model.provider <name>'"
|
||||
)
|
||||
|
||||
@@ -33,12 +33,14 @@ def _get_git_commit(project_root: Path) -> str:
|
||||
|
||||
|
||||
def _redact(value: str) -> str:
|
||||
"""Redact all but first 4 and last 4 chars."""
|
||||
if not value:
|
||||
return ""
|
||||
if len(value) < 12:
|
||||
return "***"
|
||||
return value[:4] + "..." + value[-4:]
|
||||
"""Redact all but first 4 and last 4 chars.
|
||||
|
||||
Thin wrapper over :func:`agent.redact.mask_secret`. Returns ``""`` for
|
||||
an empty value (matches the historical behavior of this helper —
|
||||
``hermes dump`` formats empty values as blank, not as ``"(not set)"``).
|
||||
"""
|
||||
from agent.redact import mask_secret
|
||||
return mask_secret(value)
|
||||
|
||||
|
||||
def _gateway_status() -> str:
|
||||
|
||||
@@ -68,7 +68,7 @@ All fields are optional. Missing values inherit from the ``default`` skin.
|
||||
welcome: "Welcome message" # Shown at CLI startup
|
||||
goodbye: "Goodbye! ⚕" # Shown on exit
|
||||
response_label: " ⚕ Hermes " # Response box header label
|
||||
prompt_symbol: "❯ " # Input prompt symbol
|
||||
prompt_symbol: "❯" # Input prompt symbol (bare token; renderers add trailing space)
|
||||
help_header: "(^_^)? Commands" # /help header text
|
||||
|
||||
# Tool prefix: character for tool output lines (default: ┊)
|
||||
@@ -103,6 +103,10 @@ BUILT-IN SKINS
|
||||
- ``slate`` — Cool blue developer-focused theme
|
||||
- ``daylight`` — Light background theme with dark text and blue accents
|
||||
- ``warm-lightmode`` — Warm brown/gold text for light terminal backgrounds
|
||||
- ``poseidon`` — Ocean-god theme (deep blue and seafoam)
|
||||
- ``sisyphus`` — Austere grayscale with boulder motif
|
||||
- ``charizard`` — Volcanic burnt-orange and ember
|
||||
- ``bunnny`` — Barbie-pink coquette theme (sparkles, hearts, bunnies)
|
||||
|
||||
USER SKINS
|
||||
==========
|
||||
@@ -190,7 +194,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
|
||||
"welcome": "Welcome to Hermes Agent! Type your message or /help for commands.",
|
||||
"goodbye": "Goodbye! ⚕",
|
||||
"response_label": " ⚕ Hermes ",
|
||||
"prompt_symbol": "❯ ",
|
||||
"prompt_symbol": "❯",
|
||||
"help_header": "(^_^)? Available Commands",
|
||||
},
|
||||
"tool_prefix": "┊",
|
||||
@@ -242,7 +246,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
|
||||
"welcome": "Welcome to Ares Agent! Type your message or /help for commands.",
|
||||
"goodbye": "Farewell, warrior! ⚔",
|
||||
"response_label": " ⚔ Ares ",
|
||||
"prompt_symbol": "⚔ ❯ ",
|
||||
"prompt_symbol": "⚔",
|
||||
"help_header": "(⚔) Available Commands",
|
||||
},
|
||||
"tool_prefix": "╎",
|
||||
@@ -301,7 +305,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
|
||||
"welcome": "Welcome to Hermes Agent! Type your message or /help for commands.",
|
||||
"goodbye": "Goodbye! ⚕",
|
||||
"response_label": " ⚕ Hermes ",
|
||||
"prompt_symbol": "❯ ",
|
||||
"prompt_symbol": "❯",
|
||||
"help_header": "[?] Available Commands",
|
||||
},
|
||||
"tool_prefix": "┊",
|
||||
@@ -340,7 +344,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
|
||||
"welcome": "Welcome to Hermes Agent! Type your message or /help for commands.",
|
||||
"goodbye": "Goodbye! ⚕",
|
||||
"response_label": " ⚕ Hermes ",
|
||||
"prompt_symbol": "❯ ",
|
||||
"prompt_symbol": "❯",
|
||||
"help_header": "(^_^)? Available Commands",
|
||||
},
|
||||
"tool_prefix": "┊",
|
||||
@@ -377,7 +381,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
|
||||
"welcome": "Welcome to Hermes Agent! Type your message or /help for commands.",
|
||||
"goodbye": "Goodbye! ⚕",
|
||||
"response_label": " ⚕ Hermes ",
|
||||
"prompt_symbol": "❯ ",
|
||||
"prompt_symbol": "❯",
|
||||
"help_header": "[?] Available Commands",
|
||||
},
|
||||
"tool_prefix": "│",
|
||||
@@ -414,7 +418,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
|
||||
"welcome": "Welcome to Hermes Agent! Type your message or /help for commands.",
|
||||
"goodbye": "Goodbye! \u2695",
|
||||
"response_label": " \u2695 Hermes ",
|
||||
"prompt_symbol": "\u276f ",
|
||||
"prompt_symbol": "\u276f",
|
||||
"help_header": "(^_^)? Available Commands",
|
||||
},
|
||||
"tool_prefix": "\u250a",
|
||||
@@ -467,7 +471,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
|
||||
"welcome": "Welcome to Poseidon Agent! Type your message or /help for commands.",
|
||||
"goodbye": "Fair winds! Ψ",
|
||||
"response_label": " Ψ Poseidon ",
|
||||
"prompt_symbol": "Ψ ❯ ",
|
||||
"prompt_symbol": "Ψ",
|
||||
"help_header": "(Ψ) Available Commands",
|
||||
},
|
||||
"tool_prefix": "│",
|
||||
@@ -539,7 +543,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
|
||||
"welcome": "Welcome to Sisyphus Agent! Type your message or /help for commands.",
|
||||
"goodbye": "The boulder waits. ◉",
|
||||
"response_label": " ◉ Sisyphus ",
|
||||
"prompt_symbol": "◉ ❯ ",
|
||||
"prompt_symbol": "◉",
|
||||
"help_header": "(◉) Available Commands",
|
||||
},
|
||||
"tool_prefix": "│",
|
||||
@@ -612,7 +616,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
|
||||
"welcome": "Welcome to Charizard Agent! Type your message or /help for commands.",
|
||||
"goodbye": "Flame out! ✦",
|
||||
"response_label": " ✦ Charizard ",
|
||||
"prompt_symbol": "✦ ❯ ",
|
||||
"prompt_symbol": "✦",
|
||||
"help_header": "(✦) Available Commands",
|
||||
},
|
||||
"tool_prefix": "│",
|
||||
@@ -636,6 +640,83 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
|
||||
[#F29C38]⠀⠀⠀⠀⠀⠀⠀⣼⡟⠀⠀⢻⣧⠀⠀⠀⠀⠀⠀⠀⠀[/]
|
||||
[dim #7A3511]⠀⠀⠀⠀⠀⠀⠀tail flame lit⠀⠀⠀⠀⠀⠀⠀⠀[/]""",
|
||||
},
|
||||
"bunnny": {
|
||||
"name": "bunnny",
|
||||
"description": "Barbie-pink coquette theme — sparkles, bows, and bubblegum",
|
||||
"colors": {
|
||||
"banner_border": "#E91E63",
|
||||
"banner_title": "#FF3366",
|
||||
"banner_accent": "#FF69B4",
|
||||
"banner_dim": "#C2185B",
|
||||
"banner_text": "#FFF0F5",
|
||||
"ui_accent": "#FF3366",
|
||||
"ui_label": "#FF69B4",
|
||||
"ui_ok": "#FFB6C1",
|
||||
"ui_error": "#FF1744",
|
||||
"ui_warn": "#FFAB91",
|
||||
"prompt": "#FFF0F5",
|
||||
"input_rule": "#E91E63",
|
||||
"response_border": "#FF69B4",
|
||||
"status_bar_bg": "#2A0E1E",
|
||||
"status_bar_text": "#FFE4EC",
|
||||
"status_bar_strong": "#FF3366",
|
||||
"status_bar_dim": "#8E4B6B",
|
||||
"status_bar_good": "#FFB6C1",
|
||||
"status_bar_warn": "#FF69B4",
|
||||
"status_bar_bad": "#FF3366",
|
||||
"status_bar_critical": "#FF1744",
|
||||
"session_label": "#FF69B4",
|
||||
"session_border": "#8E4B6B",
|
||||
"voice_status_bg": "#2A0E1E",
|
||||
"completion_menu_bg": "#2A0E1E",
|
||||
"completion_menu_current_bg": "#5A1D3A",
|
||||
"completion_menu_meta_bg": "#2A0E1E",
|
||||
"completion_menu_meta_current_bg": "#5A1D3A",
|
||||
},
|
||||
"spinner": {
|
||||
"waiting_faces": ["(♡)", "(✿)", "(✧)", "(❀)", "(ෆ)", "(˘ᵕ˘)", "(⑅)"],
|
||||
"thinking_faces": ["(♡)", "(✧)", "(❀)", "(✿)", "(ෆ)", "(˘ᵕ˘)"],
|
||||
"thinking_verbs": [
|
||||
"sparkling", "twirling", "glittering", "frosting",
|
||||
"bedazzling", "bowtying", "sprinkling sugar", "picking ribbons",
|
||||
"glossing up", "curating the vibe", "dusting pink",
|
||||
"tying a little bow", "making it cute",
|
||||
],
|
||||
"wings": [
|
||||
["⟪♡", "♡⟫"],
|
||||
["⟪✧", "✧⟫"],
|
||||
["⟪✿", "✿⟫"],
|
||||
["⟪❀", "❀⟫"],
|
||||
["⟪ෆ", "ෆ⟫"],
|
||||
],
|
||||
},
|
||||
"branding": {
|
||||
"agent_name": "Hermes Agent",
|
||||
"welcome": "hi bestie ♡ welcome to Hermes Agent! type your message or /help for commands (ノ◕ヮ◕)ノ*:・゚✧",
|
||||
"goodbye": "bye bestie ♡ ✧",
|
||||
"response_label": " ♡ Hermes ",
|
||||
"prompt_symbol": "♡",
|
||||
"help_header": "(ノ◕ヮ◕)ノ*:・゚✧ Commands",
|
||||
},
|
||||
"tool_prefix": "♡",
|
||||
"banner_logo": """[bold #FFB6C1]██╗ ██╗███████╗██████╗ ███╗ ███╗███████╗███████╗ ██╗ ██╗ [/]
|
||||
[bold #FF69B4]██║ ██║██╔════╝██╔══██╗████╗ ████║██╔════╝██╔════╝ ████████╗[/]
|
||||
[#FF3C7F]███████║█████╗ ██████╔╝██╔████╔██║█████╗ ███████╗ ╚██████╔╝[/]
|
||||
[#FF3366]██╔══██║██╔══╝ ██╔══██╗██║╚██╔╝██║██╔══╝ ╚════██║ ╚████╔╝ [/]
|
||||
[#E91E63]██║ ██║███████╗██║ ██║██║ ╚═╝ ██║███████╗███████║ ╚██╔╝ [/]
|
||||
[#C2185B]╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝ ╚═╝ [/]""",
|
||||
"banner_hero": """[#FF69B4]⠀⠀✧⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀✧⠀⠀[/]
|
||||
[#FFB6C1]⠀⠀⠀⠀⠀⠀♡⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⠀⠀⠀⠀⠀⢀⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀♡⠀⠀⠀⠀[/]
|
||||
[#FF69B4]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⣯⢬⣷⡀⠀⠀⣴⡯⢌⣧⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
|
||||
[#FF3366]⠀✿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠸⣿♡⠹⣷⠀⢸⡝♡⢸⡿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀✿⠀[/]
|
||||
[#FF3C7F]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠻⣧⣀⣿⣦⣼⡁⣠⣿⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
|
||||
[#FF3366]⠀⠀⠀⠀✧⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⡾⠋⠀⠀⠀⠈⣙⣯⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀✧[/]
|
||||
[#FF3366]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣾⠀⠀⠀⠀⠀⠀⠀⠸⡆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
|
||||
[#E91E63]⠀⠀⠀⠀⠀⠀⠀♡⠀⠀⠀⠀⠀⠀⠀⠀⢰⡧⢄⢰⡆⠀⢰⡆⡠⢄⣧⠀⠀⠀⠀⠀⠀⠀⠀♡⠀⠀⠀⠀⠀[/]
|
||||
[#C2185B]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠳⣼⣤⣤⣤⣤⣤⣧⠾⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
|
||||
[#FF69B4]⠀⠀⠀⠀⠀✿⠀⠀⠀⠀⠀⠀❀⠀⠀⠀⠀⠀❀⠀⠀❀⠀⠀⠀⠀⠀❀⠀⠀⠀⠀⠀⠀✿⠀⠀⠀⠀⠀[/]
|
||||
[dim #C2185B]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀xoxo⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]""",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -780,12 +861,21 @@ def init_skin_from_config(config: dict) -> None:
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_active_prompt_symbol(fallback: str = "❯ ") -> str:
|
||||
"""Get the interactive prompt symbol from the active skin."""
|
||||
def get_active_prompt_symbol(fallback: str = "❯") -> str:
|
||||
"""Return the interactive prompt symbol with a single trailing space.
|
||||
|
||||
Skins store ``prompt_symbol`` as a bare token (no spaces). The trailing
|
||||
space is appended here so callers can drop it straight into a rendered
|
||||
prompt without hand-rolling whitespace.
|
||||
"""
|
||||
try:
|
||||
return get_active_skin().get_branding("prompt_symbol", fallback)
|
||||
raw = get_active_skin().get_branding("prompt_symbol", fallback)
|
||||
except Exception:
|
||||
return fallback
|
||||
raw = fallback
|
||||
|
||||
cleaned = (raw or fallback).strip()
|
||||
|
||||
return f"{cleaned or fallback.strip()} "
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -26,12 +26,15 @@ def check_mark(ok: bool) -> str:
|
||||
return color("✗", Colors.RED)
|
||||
|
||||
def redact_key(key: str) -> str:
|
||||
"""Redact an API key for display."""
|
||||
if not key:
|
||||
return "(not set)"
|
||||
if len(key) < 12:
|
||||
return "***"
|
||||
return key[:4] + "..." + key[-4:]
|
||||
"""Redact an API key for display.
|
||||
|
||||
Thin wrapper over :func:`agent.redact.mask_secret`. Preserves the
|
||||
"(not set)" placeholder in dim color to match ``hermes config``'s
|
||||
output (previously this variant was missing the DIM color —
|
||||
consolidated via PR that also introduced ``mask_secret``).
|
||||
"""
|
||||
from agent.redact import mask_secret
|
||||
return mask_secret(key, empty=color("(not set)", Colors.DIM))
|
||||
|
||||
|
||||
def _format_iso_timestamp(value) -> str:
|
||||
|
||||
@@ -206,6 +206,27 @@ _LEGACY_TOOLSET_MAP = {
|
||||
# get_tool_definitions (the main schema provider)
|
||||
# =============================================================================
|
||||
|
||||
# Module-level memoization for get_tool_definitions(). Keyed on
|
||||
# (frozenset(enabled_toolsets), frozenset(disabled_toolsets), registry._generation).
|
||||
# Hot callers (gateway runner, AIAgent.__init__) invoke this on every turn
|
||||
# with quiet_mode=True; caching avoids ~7 ms of registry walking + schema
|
||||
# filtering + check_fn probing per call. Only active when quiet_mode=True
|
||||
# because quiet_mode=False has stdout side effects (tool-selection prints).
|
||||
#
|
||||
# Invalidation happens transparently via the registry's _generation counter,
|
||||
# which bumps on register() / deregister() / register_toolset_alias(). The
|
||||
# inner check_fn TTL cache in registry.py handles environment drift (Docker
|
||||
# daemon start/stop, env var changes, etc.) on a 30 s horizon.
|
||||
_tool_defs_cache: Dict[tuple, List[Dict[str, Any]]] = {}
|
||||
|
||||
|
||||
def _clear_tool_defs_cache() -> None:
|
||||
"""Drop memoized get_tool_definitions() results. Called when dynamic
|
||||
schema dependencies change (e.g. discord capability cache reset,
|
||||
execute_code sandbox reconfigured)."""
|
||||
_tool_defs_cache.clear()
|
||||
|
||||
|
||||
def get_tool_definitions(
|
||||
enabled_toolsets: List[str] = None,
|
||||
disabled_toolsets: List[str] = None,
|
||||
@@ -224,6 +245,50 @@ def get_tool_definitions(
|
||||
Returns:
|
||||
Filtered list of OpenAI-format tool definitions.
|
||||
"""
|
||||
# Fast path: memoized result when the caller doesn't need stdout prints.
|
||||
# The cache key captures every argument-level input; the registry
|
||||
# generation captures registry mutations (MCP refresh, plugin load).
|
||||
# check_fn results are TTL-cached one level down, inside
|
||||
# registry.get_definitions. The config-mtime fingerprint below captures
|
||||
# user-visible config edits that affect dynamic schemas (execute_code
|
||||
# mode, discord action allowlist, etc.) without needing an explicit
|
||||
# invalidate hook on every config-writer.
|
||||
if quiet_mode:
|
||||
try:
|
||||
from hermes_cli.config import get_config_path
|
||||
cfg_path = get_config_path()
|
||||
cfg_stat = cfg_path.stat()
|
||||
cfg_fp = (cfg_stat.st_mtime_ns, cfg_stat.st_size)
|
||||
except (FileNotFoundError, OSError, ImportError):
|
||||
cfg_fp = None
|
||||
cache_key = (
|
||||
frozenset(enabled_toolsets) if enabled_toolsets is not None else None,
|
||||
frozenset(disabled_toolsets) if disabled_toolsets else None,
|
||||
registry._generation,
|
||||
cfg_fp,
|
||||
)
|
||||
cached = _tool_defs_cache.get(cache_key)
|
||||
if cached is not None:
|
||||
# Update _last_resolved_tool_names so downstream callers see
|
||||
# consistent state even on a cache hit.
|
||||
global _last_resolved_tool_names
|
||||
_last_resolved_tool_names = [t["function"]["name"] for t in cached]
|
||||
# Return a shallow copy of the list but share the dict references —
|
||||
# schemas are treated as read-only by all known callers.
|
||||
return list(cached)
|
||||
|
||||
result = _compute_tool_definitions(enabled_toolsets, disabled_toolsets, quiet_mode)
|
||||
if quiet_mode:
|
||||
_tool_defs_cache[cache_key] = result
|
||||
return result
|
||||
|
||||
|
||||
def _compute_tool_definitions(
|
||||
enabled_toolsets: List[str] = None,
|
||||
disabled_toolsets: List[str] = None,
|
||||
quiet_mode: bool = False,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Uncached implementation of :func:`get_tool_definitions`."""
|
||||
# Determine which tool names the caller wants
|
||||
tools_to_include: set = set()
|
||||
|
||||
|
||||
11
nix/lib.nix
11
nix/lib.nix
@@ -165,6 +165,17 @@
|
||||
|
||||
NEW_HASH=$(echo "$OUTPUT" | awk '/got:/ {print $2; exit}')
|
||||
if [ -z "$NEW_HASH" ]; then
|
||||
# Magic-Nix-Cache occasionally returns HTTP 418 / cache-throttled
|
||||
# mid-run; nix then prints "outputs … not valid, so checking is
|
||||
# not possible" without a `got:` line. That's an infrastructure
|
||||
# blip, not a stale lockfile — warn + skip rather than failing
|
||||
# the lint. A real hash mismatch would still surface in the
|
||||
# primary `.#$ATTR` build, which is a separate CI job.
|
||||
if echo "$OUTPUT" | grep -qE "throttled|HTTP error 418|substituter .* is disabled|some outputs of .* are not valid"; then
|
||||
echo " skipped (transient cache failure — see primary nix build for real status)" >&2
|
||||
echo "$OUTPUT" | tail -8 >&2
|
||||
continue
|
||||
fi
|
||||
echo " build failed with no hash mismatch:" >&2
|
||||
echo "$OUTPUT" | tail -40 >&2
|
||||
exit 1
|
||||
|
||||
@@ -4,7 +4,7 @@ let
|
||||
src = ../web;
|
||||
npmDeps = pkgs.fetchNpmDeps {
|
||||
inherit src;
|
||||
hash = "sha256-AahWmJ9gDQ9pMPa1FYwUjYdO2mOi6JM9Mst27E0vp68=";
|
||||
hash = "sha256-+B2+Fe4djPzHHcUXRx+m0cuyaopAhW0PcHsMgYfV5VE=";
|
||||
};
|
||||
|
||||
npm = hermesNpmLib.mkNpmPassthru { folder = "web"; attr = "web"; pname = "hermes-web"; };
|
||||
|
||||
166
run_agent.py
166
run_agent.py
@@ -3230,49 +3230,135 @@ class AIAgent:
|
||||
)
|
||||
|
||||
_SKILL_REVIEW_PROMPT = (
|
||||
"Review the conversation above and consider whether a skill should be saved or updated.\n\n"
|
||||
"Work in this order — do not skip steps:\n\n"
|
||||
"1. SURVEY the existing skill landscape first. Call skills_list to see what you "
|
||||
"have. If anything looks potentially relevant, skill_view it before deciding. "
|
||||
"You are looking for the CLASS of task that just happened, not the exact task. "
|
||||
"Example: a successful Tauri build is in the class \"desktop app build "
|
||||
"troubleshooting\", not \"fix my specific Tauri error today\".\n\n"
|
||||
"2. THINK CLASS-FIRST. What general pattern of task did the user just complete? "
|
||||
"What conditions will trigger this pattern again? Describe the class in one "
|
||||
"sentence before looking at what to save.\n\n"
|
||||
"3. PREFER GENERALIZING AN EXISTING SKILL over creating a new one. If a skill "
|
||||
"already covers the class — even partially — update it (skill_manage patch) "
|
||||
"with the new insight. Broaden its \"when to use\" trigger if needed.\n\n"
|
||||
"4. ONLY CREATE A NEW SKILL when no existing skill reasonably covers the class. "
|
||||
"When you create one, name and scope it at the class level "
|
||||
"(\"react-i18n-setup\", not \"add-i18n-to-my-dashboard-app\"). The trigger "
|
||||
"section must describe the class of situations, not this one session.\n\n"
|
||||
"5. If you notice two existing skills that overlap, note it in your response "
|
||||
"so a future review can consolidate them. Do not consolidate now unless the "
|
||||
"overlap is obvious and low-risk.\n\n"
|
||||
"Only act when something is genuinely worth saving. "
|
||||
"If nothing stands out, just say 'Nothing to save.' and stop."
|
||||
"Review the conversation above and update the skill library. Be "
|
||||
"ACTIVE — most sessions produce at least one skill update, even if "
|
||||
"small. A pass that does nothing is a missed learning opportunity, "
|
||||
"not a neutral outcome.\n\n"
|
||||
"Target shape of the library: CLASS-LEVEL skills, each with a rich "
|
||||
"SKILL.md and a `references/` directory for session-specific detail. "
|
||||
"Not a long flat list of narrow one-session-one-skill entries. This "
|
||||
"shapes HOW you update, not WHETHER you update.\n\n"
|
||||
"Signals to look for (any one of these warrants action):\n"
|
||||
" • User corrected your style, tone, format, legibility, or "
|
||||
"verbosity. Frustration signals like 'stop doing X', 'this is too "
|
||||
"verbose', 'don't format like this', 'why are you explaining', "
|
||||
"'just give me the answer', 'you always do Y and I hate it', or an "
|
||||
"explicit 'remember this' are FIRST-CLASS skill signals, not just "
|
||||
"memory signals. Update the relevant skill(s) to embed the "
|
||||
"preference so the next session starts already knowing.\n"
|
||||
" • User corrected your workflow, approach, or sequence of steps. "
|
||||
"Encode the correction as a pitfall or explicit step in the skill "
|
||||
"that governs that class of task.\n"
|
||||
" • Non-trivial technique, fix, workaround, debugging path, or "
|
||||
"tool-usage pattern emerged that a future session would benefit "
|
||||
"from. Capture it.\n"
|
||||
" • A skill that got loaded or consulted this session turned out "
|
||||
"to be wrong, missing a step, or outdated. Patch it NOW.\n\n"
|
||||
"Preference order — prefer the earliest action that fits, but do "
|
||||
"pick one when a signal above fired:\n"
|
||||
" 1. UPDATE A CURRENTLY-LOADED SKILL. Look back through the "
|
||||
"conversation for skills the user loaded via /skill-name or you "
|
||||
"read via skill_view. If any of them covers the territory of the "
|
||||
"new learning, PATCH that one first. It is the skill that was in "
|
||||
"play, so it's the right one to extend.\n"
|
||||
" 2. UPDATE AN EXISTING UMBRELLA (via skills_list + skill_view). "
|
||||
"If no loaded skill fits but an existing class-level skill does, "
|
||||
"patch it. Add a subsection, a pitfall, or broaden a trigger.\n"
|
||||
" 3. ADD A SUPPORT FILE under an existing umbrella. Skills can be "
|
||||
"packaged with three kinds of support files — use the right "
|
||||
"directory per kind:\n"
|
||||
" • `references/<topic>.md` — session-specific detail (error "
|
||||
"transcripts, reproduction recipes, provider quirks) AND "
|
||||
"condensed knowledge banks: quoted research, API docs, external "
|
||||
"authoritative excerpts, or domain notes you found while working "
|
||||
"on the problem. Write it concise and for the value of the task, "
|
||||
"not as a full mirror of upstream docs.\n"
|
||||
" • `templates/<name>.<ext>` — starter files meant to be "
|
||||
"copied and modified (boilerplate configs, scaffolding, a "
|
||||
"known-good example the agent can `reproduce with modifications`).\n"
|
||||
" • `scripts/<name>.<ext>` — statically re-runnable actions "
|
||||
"the skill can invoke directly (verification scripts, fixture "
|
||||
"generators, deterministic probes, anything the agent should run "
|
||||
"rather than hand-type each time).\n"
|
||||
" Add support files via skill_manage action=write_file with "
|
||||
"file_path starting 'references/', 'templates/', or 'scripts/'. "
|
||||
"The umbrella's SKILL.md should gain a one-line pointer to any "
|
||||
"new support file so future agents know it exists.\n"
|
||||
" 4. CREATE A NEW CLASS-LEVEL UMBRELLA SKILL when no existing "
|
||||
"skill covers the class. The name MUST be at the class level. "
|
||||
"The name MUST NOT be a specific PR number, error string, feature "
|
||||
"codename, library-alone name, or 'fix-X / debug-Y / audit-Z-today' "
|
||||
"session artifact. If the proposed name only makes sense for "
|
||||
"today's task, it's wrong — fall back to (1), (2), or (3).\n\n"
|
||||
"User-preference embedding (important): when the user expressed a "
|
||||
"style/format/workflow preference, the update belongs in the "
|
||||
"SKILL.md body, not just in memory. Memory captures 'who the user "
|
||||
"is and what the current situation and state of your operations "
|
||||
"are'; skills capture 'how to do this class of task for this "
|
||||
"user'. When they complain about how you handled a task, the "
|
||||
"skill that governs that task needs to carry the lesson.\n\n"
|
||||
"If you notice two existing skills that overlap, note it in your "
|
||||
"reply — the background curator handles consolidation at scale.\n\n"
|
||||
"'Nothing to save.' is a real option but should NOT be the "
|
||||
"default. If the session ran smoothly with no corrections and "
|
||||
"produced no new technique, just say 'Nothing to save.' and stop. "
|
||||
"Otherwise, act."
|
||||
)
|
||||
|
||||
_COMBINED_REVIEW_PROMPT = (
|
||||
"Review the conversation above and consider two things:\n\n"
|
||||
"**Memory**: Has the user revealed things about themselves — their persona, "
|
||||
"desires, preferences, or personal details? Has the user expressed expectations "
|
||||
"about how you should behave, their work style, or ways they want you to operate? "
|
||||
"If so, save using the memory tool.\n\n"
|
||||
"**Skills**: Was a non-trivial approach used to complete a task that required trial "
|
||||
"and error, changing course due to experiential findings, or a different method "
|
||||
"or outcome than the user expected? If so, work in this order:\n"
|
||||
" a. SURVEY existing skills first (skills_list, then skill_view on candidates).\n"
|
||||
" b. Identify the CLASS of task, not the specific task "
|
||||
"(\"desktop app build troubleshooting\", not \"fix my Tauri error\").\n"
|
||||
" c. PREFER UPDATING/GENERALIZING an existing skill that covers the class.\n"
|
||||
" d. ONLY CREATE A NEW SKILL if no existing one covers the class. Scope at "
|
||||
"the class level, not this one session.\n"
|
||||
" e. If you notice overlapping skills during the survey, note it so a future "
|
||||
"review can consolidate them.\n\n"
|
||||
"Only act if there's something genuinely worth saving. "
|
||||
"If nothing stands out, just say 'Nothing to save.' and stop."
|
||||
"Review the conversation above and update two things:\n\n"
|
||||
"**Memory**: who the user is. Did the user reveal persona, "
|
||||
"desires, preferences, personal details, or expectations about "
|
||||
"how you should behave? Save facts about the user and durable "
|
||||
"preferences with the memory tool.\n\n"
|
||||
"**Skills**: how to do this class of task. Be ACTIVE — most "
|
||||
"sessions produce at least one skill update. A pass that does "
|
||||
"nothing is a missed learning opportunity, not a neutral outcome.\n\n"
|
||||
"Target shape of the skill library: CLASS-LEVEL skills with a rich "
|
||||
"SKILL.md and a `references/` directory for session-specific detail. "
|
||||
"Not a long flat list of narrow one-session-one-skill entries.\n\n"
|
||||
"Signals that warrant a skill update (any one is enough):\n"
|
||||
" • User corrected your style, tone, format, legibility, "
|
||||
"verbosity, or approach. Frustration is a FIRST-CLASS skill "
|
||||
"signal, not just a memory signal. 'stop doing X', 'don't format "
|
||||
"like this', 'I hate when you Y' — embed the lesson in the skill "
|
||||
"that governs that task so the next session starts fixed.\n"
|
||||
" • Non-trivial technique, fix, workaround, or debugging path "
|
||||
"emerged.\n"
|
||||
" • A skill that was loaded or consulted turned out wrong, "
|
||||
"missing, or outdated — patch it now.\n\n"
|
||||
"Preference order for skills — pick the earliest that fits:\n"
|
||||
" 1. UPDATE A CURRENTLY-LOADED SKILL. Check what skills were "
|
||||
"loaded via /skill-name or skill_view in the conversation. If one "
|
||||
"of them covers the learning, PATCH it first. It was in play; "
|
||||
"it's the right place.\n"
|
||||
" 2. UPDATE AN EXISTING UMBRELLA (skills_list + skill_view to "
|
||||
"find the right one). Patch it.\n"
|
||||
" 3. ADD A SUPPORT FILE under an existing umbrella via "
|
||||
"skill_manage action=write_file. Three kinds: "
|
||||
"`references/<topic>.md` for session-specific detail OR condensed "
|
||||
"knowledge banks (quoted research, API docs excerpts, domain "
|
||||
"notes) written concise and task-focused; `templates/<name>.<ext>` "
|
||||
"for starter files meant to be copied and modified; "
|
||||
"`scripts/<name>.<ext>` for statically re-runnable actions "
|
||||
"(verification, fixture generators, probes). Add a one-line "
|
||||
"pointer in SKILL.md so future agents find them.\n"
|
||||
" 4. CREATE A NEW CLASS-LEVEL UMBRELLA when nothing exists. "
|
||||
"Name at the class level — NOT a PR number, error string, "
|
||||
"codename, library-alone name, or 'fix-X / debug-Y' session "
|
||||
"artifact. If the name only fits today's task, fall back to (1), "
|
||||
"(2), or (3).\n\n"
|
||||
"User-preference embedding: when the user complains about how "
|
||||
"you handled a task, update the skill that governs that task — "
|
||||
"memory alone isn't enough. Memory says 'who the user is and "
|
||||
"what the current situation and state of your operations are'; "
|
||||
"skills say 'how to do this class of task for this user'. Both "
|
||||
"should carry user-preference lessons when relevant.\n\n"
|
||||
"If you notice overlapping existing skills, mention it — the "
|
||||
"background curator handles consolidation.\n\n"
|
||||
"Act on whichever of the two dimensions has real signal. If "
|
||||
"genuinely nothing stands out on either, say 'Nothing to save.' "
|
||||
"and stop — but don't reach for that conclusion as a default."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -84,6 +84,7 @@ AUTHOR_MAP = {
|
||||
"6548898+romanornr@users.noreply.github.com": "romanornr",
|
||||
"foxion37@gmail.com": "foxion37",
|
||||
"bloodcarter@gmail.com": "bloodcarter",
|
||||
"scott@scotttrinh.com": "scotttrinh",
|
||||
# contributors (from noreply pattern)
|
||||
"david.vv@icloud.com": "davidvv",
|
||||
"wangqiang@wangqiangdeMac-mini.local": "xiaoqiang243",
|
||||
|
||||
@@ -40,14 +40,14 @@ class TestCliSkinPromptIntegration:
|
||||
cli = _make_cli_stub()
|
||||
|
||||
set_active_skin("ares")
|
||||
assert cli._get_tui_prompt_fragments() == [("class:prompt", "⚔ ❯ ")]
|
||||
assert cli._get_tui_prompt_fragments() == [("class:prompt", "⚔ ")]
|
||||
|
||||
def test_secret_prompt_fragments_preserve_secret_state(self):
|
||||
cli = _make_cli_stub()
|
||||
cli._secret_state = {"response_queue": object()}
|
||||
|
||||
set_active_skin("ares")
|
||||
assert cli._get_tui_prompt_fragments() == [("class:sudo-prompt", "🔑 ❯ ")]
|
||||
assert cli._get_tui_prompt_fragments() == [("class:sudo-prompt", "🔑 ⚔ ")]
|
||||
|
||||
def test_narrow_terminals_compact_voice_prompt_fragments(self):
|
||||
cli = _make_cli_stub()
|
||||
|
||||
@@ -480,3 +480,29 @@ def _enforce_test_timeout():
|
||||
yield
|
||||
signal.alarm(0)
|
||||
signal.signal(signal.SIGALRM, old)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_tool_registry_caches():
|
||||
"""Clear tool-registry-level caches between tests.
|
||||
|
||||
The production registry caches ``check_fn()`` results for 30 s
|
||||
(see tools/registry.py) and :func:`get_tool_definitions` memoizes
|
||||
its result (see model_tools.py). Both are keyed on state that tests
|
||||
routinely mutate (env vars, registry._generation, config.yaml mtime)
|
||||
— but a stale result from test A can still be served to test B
|
||||
because 30 s covers the entire suite, and xdist worker reuse means
|
||||
one test's cache lands in another's process. Clearing before every
|
||||
test keeps hermetic behavior.
|
||||
"""
|
||||
try:
|
||||
from tools.registry import invalidate_check_fn_cache
|
||||
invalidate_check_fn_cache()
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from model_tools import _clear_tool_defs_cache
|
||||
_clear_tool_defs_cache()
|
||||
except ImportError:
|
||||
pass
|
||||
yield
|
||||
|
||||
@@ -345,6 +345,59 @@ def test_run_doctor_accepts_bare_custom_provider(monkeypatch, tmp_path):
|
||||
assert "model.provider 'custom' is not a recognised provider" not in out
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("provider", "default_model"),
|
||||
[
|
||||
("ai-gateway", "anthropic/claude-sonnet-4.6"),
|
||||
("opencode-zen", "anthropic/claude-sonnet-4.6"),
|
||||
("kilocode", "anthropic/claude-sonnet-4.6"),
|
||||
("kimi-coding", "kimi-k2"),
|
||||
],
|
||||
)
|
||||
def test_run_doctor_accepts_hermes_provider_ids_that_catalog_aliases(
|
||||
monkeypatch, tmp_path, provider, default_model
|
||||
):
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
(home / "config.yaml").write_text(
|
||||
"model:\n"
|
||||
f" provider: {provider}\n"
|
||||
f" default: {default_model}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
|
||||
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
|
||||
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
|
||||
(tmp_path / "project").mkdir(exist_ok=True)
|
||||
|
||||
fake_model_tools = types.SimpleNamespace(
|
||||
check_tool_availability=lambda *a, **kw: ([], []),
|
||||
TOOLSET_REQUIREMENTS={},
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
|
||||
|
||||
try:
|
||||
from hermes_cli import auth as _auth_mod
|
||||
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
|
||||
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
doctor_mod.run_doctor(Namespace(fix=False))
|
||||
|
||||
out = buf.getvalue()
|
||||
assert f"model.provider '{provider}' is not a recognised provider" not in out
|
||||
assert f"model.provider '{provider}' is unknown" not in out
|
||||
if provider in {"ai-gateway", "opencode-zen", "kilocode"}:
|
||||
assert (
|
||||
f"model.default '{default_model}' uses a vendor/model slug but provider is '{provider}'"
|
||||
not in out
|
||||
)
|
||||
|
||||
|
||||
def test_run_doctor_termux_does_not_mark_browser_available_without_agent_browser(monkeypatch, tmp_path):
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -252,7 +252,7 @@ class TestCliBrandingHelpers:
|
||||
from hermes_cli.skin_engine import set_active_skin, get_active_prompt_symbol
|
||||
|
||||
set_active_skin("ares")
|
||||
assert get_active_prompt_symbol() == "⚔ ❯ "
|
||||
assert get_active_prompt_symbol() == "⚔ "
|
||||
|
||||
def test_active_help_header_ares(self):
|
||||
from hermes_cli.skin_engine import set_active_skin, get_active_help_header
|
||||
|
||||
@@ -1,67 +1,176 @@
|
||||
"""Behavior tests for the class-first skill review prompts.
|
||||
"""Behavior tests for the skill review / combined review prompts.
|
||||
|
||||
The skill review / combined review prompts steer the background review agent
|
||||
toward generalizing existing skills rather than accumulating near-duplicates.
|
||||
These tests assert the behavioral *instructions* are present — they do NOT
|
||||
The review prompts steer the background review agent toward actively updating
|
||||
the skill library after most sessions, with a strong bias toward:
|
||||
1. Patching currently-loaded skills first,
|
||||
2. Patching existing umbrellas next,
|
||||
3. Adding references/ files under an existing umbrella,
|
||||
4. Creating a new class-level umbrella only when nothing else fits.
|
||||
|
||||
User-preference corrections (style, format, verbosity, legibility) are
|
||||
first-class skill signals, not just memory signals.
|
||||
|
||||
These tests assert behavioral *instructions* are present — they do NOT
|
||||
snapshot the full prompt text (change-detector).
|
||||
"""
|
||||
|
||||
from run_agent import AIAgent
|
||||
|
||||
|
||||
def test_skill_review_prompt_instructs_survey_first():
|
||||
"""Prompt must tell the reviewer to list existing skills before deciding."""
|
||||
# ---------------------------------------------------------------------------
|
||||
# _SKILL_REVIEW_PROMPT
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_skill_review_prompt_biases_toward_active_updates():
|
||||
"""Prompt must frame updating as the default stance, not something rare."""
|
||||
prompt = AIAgent._SKILL_REVIEW_PROMPT
|
||||
assert "skills_list" in prompt, "must instruct the reviewer to call skills_list"
|
||||
assert "skill_view" in prompt, "must instruct the reviewer to skill_view candidates"
|
||||
assert "SURVEY" in prompt, "must name the survey step explicitly"
|
||||
|
||||
|
||||
def test_skill_review_prompt_is_class_first():
|
||||
"""Prompt must steer toward the CLASS of task, not the specific task."""
|
||||
prompt = AIAgent._SKILL_REVIEW_PROMPT
|
||||
assert "CLASS" in prompt, "must tell the reviewer to think about the task class"
|
||||
assert "class level" in prompt, "must anchor naming at the class level"
|
||||
|
||||
|
||||
def test_skill_review_prompt_prefers_updating_existing():
|
||||
"""Prompt must prefer generalizing an existing skill over creating a new one."""
|
||||
prompt = AIAgent._SKILL_REVIEW_PROMPT
|
||||
assert "PREFER GENERALIZING" in prompt or "PREFER UPDATING" in prompt, (
|
||||
"must state the update-over-create preference"
|
||||
assert "ACTIVE" in prompt or "active" in prompt.lower(), (
|
||||
"must tell the reviewer to be active"
|
||||
)
|
||||
assert "ONLY CREATE A NEW SKILL" in prompt, (
|
||||
"must gate new-skill creation behind a last-resort clause"
|
||||
# "missed learning opportunity" or equivalent framing for not acting
|
||||
assert "missed" in prompt.lower() or "opportunity" in prompt.lower(), (
|
||||
"must frame inaction as a miss, not a neutral outcome"
|
||||
)
|
||||
|
||||
|
||||
def test_skill_review_prompt_flags_overlap_for_followup():
|
||||
"""Prompt must ask the reviewer to note overlapping skills for future review."""
|
||||
def test_skill_review_prompt_treats_user_corrections_as_skill_signal():
|
||||
"""Style/format/verbosity complaints must be FIRST-CLASS skill signals, not just memory."""
|
||||
prompt = AIAgent._SKILL_REVIEW_PROMPT
|
||||
assert "overlap" in prompt.lower(), "must mention the overlap-flagging protocol"
|
||||
lower = prompt.lower()
|
||||
# Must mention style/format/verbosity-family corrections
|
||||
assert any(k in lower for k in ("style", "format", "verbos", "legib", "tone")), (
|
||||
"must name style/format/verbosity/legibility as signals"
|
||||
)
|
||||
# Must frame these as first-class skill signals (not memory-only)
|
||||
assert "FIRST-CLASS" in prompt or "first-class" in prompt, (
|
||||
"must explicitly label user-preference corrections as first-class skill signals"
|
||||
)
|
||||
# Must mention the correction-type phrases to tune the model's ear
|
||||
assert "stop doing" in lower or "don't" in lower or "hate" in lower or "frustrat" in lower, (
|
||||
"must give concrete phrasing examples so the model recognizes corrections"
|
||||
)
|
||||
|
||||
|
||||
def test_skill_review_prompt_preserves_opt_out_clause():
|
||||
"""The 'Nothing to save.' escape clause must remain."""
|
||||
def test_skill_review_prompt_prefers_loaded_skills_first():
|
||||
"""Currently-loaded skills must be the first patch target."""
|
||||
prompt = AIAgent._SKILL_REVIEW_PROMPT
|
||||
assert "LOADED" in prompt or "loaded" in prompt, (
|
||||
"must mention currently-loaded skills"
|
||||
)
|
||||
# Must name the mechanisms for detecting loaded skills
|
||||
assert "skill_view" in prompt and "/skill" in prompt, (
|
||||
"must name skill_view and /skill-name as loaded-skill signals"
|
||||
)
|
||||
|
||||
|
||||
def test_skill_review_prompt_has_four_step_preference_order():
|
||||
"""The 4-step patch/support-file/create ladder must be present."""
|
||||
prompt = AIAgent._SKILL_REVIEW_PROMPT
|
||||
assert "PATCH" in prompt
|
||||
assert "references/" in prompt or "REFERENCE" in prompt
|
||||
assert "CREATE" in prompt
|
||||
assert "UMBRELLA" in prompt or "umbrella" in prompt
|
||||
|
||||
|
||||
def test_skill_review_prompt_names_three_support_file_kinds():
|
||||
"""Support-file step must name references/, templates/, and scripts/."""
|
||||
prompt = AIAgent._SKILL_REVIEW_PROMPT
|
||||
assert "references/" in prompt, "must name references/ as a support-file kind"
|
||||
assert "templates/" in prompt, "must name templates/ as a support-file kind"
|
||||
assert "scripts/" in prompt, "must name scripts/ as a support-file kind"
|
||||
# Purpose hints for each kind
|
||||
assert "knowledge" in prompt.lower() or "research" in prompt.lower() or "API docs" in prompt, (
|
||||
"must mention knowledge-bank / research / API-docs role of references/"
|
||||
)
|
||||
assert "copied" in prompt.lower() or "starter" in prompt.lower() or "reproduce" in prompt.lower(), (
|
||||
"must mention that templates/ are starter files to copy/modify"
|
||||
)
|
||||
assert "re-runnable" in prompt.lower() or "verification" in prompt.lower() or "probe" in prompt.lower(), (
|
||||
"must mention that scripts/ are re-runnable actions"
|
||||
)
|
||||
|
||||
|
||||
def test_skill_review_prompt_has_name_veto_for_create():
|
||||
"""Creating a new skill must be gated behind class-level naming."""
|
||||
prompt = AIAgent._SKILL_REVIEW_PROMPT
|
||||
assert "class level" in prompt.lower() or "CLASS-LEVEL" in prompt
|
||||
assert "MUST NOT" in prompt or "must not" in prompt, (
|
||||
"must have a name-veto clause blocking session-artifact names"
|
||||
)
|
||||
|
||||
|
||||
def test_skill_review_prompt_embeds_user_preferences_in_skills():
|
||||
"""Must explicitly say user-preference lessons belong in SKILL.md, not only memory."""
|
||||
prompt = AIAgent._SKILL_REVIEW_PROMPT
|
||||
lower = prompt.lower()
|
||||
assert "preference" in lower, "must mention user preferences"
|
||||
assert "memory" in lower and "skill" in lower, (
|
||||
"must contrast memory vs skill responsibilities"
|
||||
)
|
||||
|
||||
|
||||
def test_skill_review_prompt_flags_overlap_and_defers_to_curator():
|
||||
"""Reviewer should not consolidate live; flag overlap for the curator."""
|
||||
prompt = AIAgent._SKILL_REVIEW_PROMPT
|
||||
assert "overlap" in prompt.lower()
|
||||
assert "curator" in prompt.lower(), "must defer consolidation to the curator"
|
||||
|
||||
|
||||
def test_skill_review_prompt_still_has_opt_out_clause():
|
||||
"""'Nothing to save.' must remain as a real-but-not-default option."""
|
||||
prompt = AIAgent._SKILL_REVIEW_PROMPT
|
||||
assert "Nothing to save." in prompt
|
||||
|
||||
|
||||
def test_combined_review_prompt_keeps_memory_section():
|
||||
"""Combined prompt must still cover memory review."""
|
||||
# ---------------------------------------------------------------------------
|
||||
# _COMBINED_REVIEW_PROMPT
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_combined_review_prompt_has_memory_section():
|
||||
"""Memory half must still cover user facts and preferences."""
|
||||
prompt = AIAgent._COMBINED_REVIEW_PROMPT
|
||||
assert "**Memory**" in prompt
|
||||
assert "memory tool" in prompt
|
||||
|
||||
|
||||
def test_combined_review_prompt_skills_section_is_class_first():
|
||||
"""The **Skills** half of the combined prompt must follow the same protocol."""
|
||||
def test_combined_review_prompt_skills_biased_toward_active_updates():
|
||||
"""Skills half must carry the active-update bias."""
|
||||
prompt = AIAgent._COMBINED_REVIEW_PROMPT
|
||||
assert "**Skills**" in prompt
|
||||
assert "SURVEY" in prompt
|
||||
assert "CLASS" in prompt
|
||||
assert "skills_list" in prompt
|
||||
assert "ONLY CREATE A NEW SKILL" in prompt
|
||||
assert "ACTIVE" in prompt or "active" in prompt.lower()
|
||||
assert "missed" in prompt.lower() or "opportunity" in prompt.lower()
|
||||
|
||||
|
||||
def test_combined_review_prompt_treats_user_corrections_as_skill_signal():
|
||||
"""Combined prompt must carry the same user-preference-is-skill-signal rule."""
|
||||
prompt = AIAgent._COMBINED_REVIEW_PROMPT
|
||||
lower = prompt.lower()
|
||||
assert any(k in lower for k in ("style", "format", "verbos", "legib", "tone"))
|
||||
assert "FIRST-CLASS" in prompt or "first-class" in prompt
|
||||
|
||||
|
||||
def test_combined_review_prompt_prefers_loaded_skills_first():
|
||||
"""Combined prompt must also prefer loaded skills first."""
|
||||
prompt = AIAgent._COMBINED_REVIEW_PROMPT
|
||||
assert "LOADED" in prompt or "loaded" in prompt
|
||||
assert "skill_view" in prompt and "/skill" in prompt
|
||||
|
||||
|
||||
def test_combined_review_prompt_has_four_step_skill_ladder():
|
||||
"""Combined prompt must keep the patch/support-file/create ladder on the Skills half."""
|
||||
prompt = AIAgent._COMBINED_REVIEW_PROMPT
|
||||
assert "PATCH" in prompt
|
||||
assert "references/" in prompt or "REFERENCE" in prompt
|
||||
assert "CREATE" in prompt
|
||||
assert "CLASS-LEVEL" in prompt or "class-level" in prompt or "class level" in prompt.lower()
|
||||
|
||||
|
||||
def test_combined_review_prompt_names_three_support_file_kinds():
|
||||
"""Combined prompt must also name all three support-file kinds."""
|
||||
prompt = AIAgent._COMBINED_REVIEW_PROMPT
|
||||
assert "references/" in prompt
|
||||
assert "templates/" in prompt
|
||||
assert "scripts/" in prompt
|
||||
|
||||
|
||||
def test_combined_review_prompt_preserves_opt_out_clause():
|
||||
@@ -69,10 +178,14 @@ def test_combined_review_prompt_preserves_opt_out_clause():
|
||||
assert "Nothing to save." in prompt
|
||||
|
||||
|
||||
def test_memory_review_prompt_unchanged_in_structure():
|
||||
# ---------------------------------------------------------------------------
|
||||
# _MEMORY_REVIEW_PROMPT — unchanged, still memory-focused
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_memory_review_prompt_still_focused_on_user_facts():
|
||||
"""Memory-only review prompt stays focused on user facts — not touched by this change."""
|
||||
prompt = AIAgent._MEMORY_REVIEW_PROMPT
|
||||
# Guardrails: the memory-only prompt must NOT mention skills/surveys.
|
||||
# The memory-only prompt should NOT drift into skill territory
|
||||
assert "skills_list" not in prompt
|
||||
assert "SURVEY" not in prompt
|
||||
assert "memory tool" in prompt
|
||||
|
||||
@@ -40,14 +40,14 @@ class TestCliSkinPromptIntegration:
|
||||
cli = _make_cli_stub()
|
||||
|
||||
set_active_skin("ares")
|
||||
assert cli._get_tui_prompt_fragments() == [("class:prompt", "⚔ ❯ ")]
|
||||
assert cli._get_tui_prompt_fragments() == [("class:prompt", "⚔ ")]
|
||||
|
||||
def test_secret_prompt_fragments_preserve_secret_state(self):
|
||||
cli = _make_cli_stub()
|
||||
cli._secret_state = {"response_queue": object()}
|
||||
|
||||
set_active_skin("ares")
|
||||
assert cli._get_tui_prompt_fragments() == [("class:sudo-prompt", "🔑 ❯ ")]
|
||||
assert cli._get_tui_prompt_fragments() == [("class:sudo-prompt", "🔑 ⚔ ")]
|
||||
|
||||
def test_icon_only_skin_symbol_still_visible_in_special_states(self):
|
||||
cli = _make_cli_stub()
|
||||
|
||||
@@ -944,6 +944,39 @@ def test_config_set_section_rejects_unknown_section_or_mode(tmp_path, monkeypatc
|
||||
assert bad_mode["error"]["code"] == 4002
|
||||
|
||||
|
||||
def test_config_mouse_uses_documented_key_with_legacy_fallback(monkeypatch):
|
||||
cfg = {"display": {"tui_mouse": False}}
|
||||
writes = []
|
||||
|
||||
monkeypatch.setattr(server, "_load_cfg", lambda: cfg)
|
||||
monkeypatch.setattr(
|
||||
server, "_write_config_key", lambda path, value: writes.append((path, value))
|
||||
)
|
||||
|
||||
get_legacy = server.handle_request(
|
||||
{"id": "1", "method": "config.get", "params": {"key": "mouse"}}
|
||||
)
|
||||
assert get_legacy["result"]["value"] == "off"
|
||||
|
||||
set_toggle = server.handle_request(
|
||||
{"id": "2", "method": "config.set", "params": {"key": "mouse"}}
|
||||
)
|
||||
assert set_toggle["result"] == {"key": "mouse", "value": "on"}
|
||||
assert writes == [("display.mouse_tracking", True)]
|
||||
|
||||
cfg["display"] = {"mouse_tracking": 0, "tui_mouse": True}
|
||||
get_canonical = server.handle_request(
|
||||
{"id": "3", "method": "config.get", "params": {"key": "mouse"}}
|
||||
)
|
||||
assert get_canonical["result"]["value"] == "off"
|
||||
|
||||
cfg["display"] = {"mouse_tracking": None, "tui_mouse": False}
|
||||
get_null = server.handle_request(
|
||||
{"id": "4", "method": "config.get", "params": {"key": "mouse"}}
|
||||
)
|
||||
assert get_null["result"]["value"] == "on"
|
||||
|
||||
|
||||
def test_enable_gateway_prompts_sets_gateway_env(monkeypatch):
|
||||
monkeypatch.delenv("HERMES_EXEC_ASK", raising=False)
|
||||
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
|
||||
@@ -3010,3 +3043,96 @@ def test_browser_manage_disconnect_drops_env_and_cleans(monkeypatch):
|
||||
assert "BROWSER_CDP_URL" not in os.environ
|
||||
# Two cleanups: once before env removal, once after, matching connect.
|
||||
assert cleanup_count["n"] == 2
|
||||
|
||||
|
||||
# ── config.get indicator normalization ───────────────────────────────
|
||||
|
||||
|
||||
def test_config_get_indicator_returns_known_value_verbatim(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
server, "_load_cfg", lambda: {"display": {"tui_status_indicator": "emoji"}}
|
||||
)
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "config.get", "params": {"key": "indicator"}}
|
||||
)
|
||||
assert resp["result"] == {"value": "emoji"}
|
||||
|
||||
|
||||
def test_config_get_indicator_normalizes_casing_and_whitespace(monkeypatch):
|
||||
"""Hand-edited config.yaml stays consistent with what the TUI shows.
|
||||
|
||||
Frontend's `normalizeIndicatorStyle` lowercases + trims, so config.get
|
||||
must do the same — otherwise `/indicator` prints 'EMOJI ' while the
|
||||
UI is actually rendering the kaomoji default."""
|
||||
monkeypatch.setattr(
|
||||
server, "_load_cfg", lambda: {"display": {"tui_status_indicator": " EMOJI "}}
|
||||
)
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "config.get", "params": {"key": "indicator"}}
|
||||
)
|
||||
assert resp["result"] == {"value": "emoji"}
|
||||
|
||||
|
||||
def test_config_get_indicator_falls_back_to_default_for_unknown(monkeypatch):
|
||||
"""An unknown value in config.yaml falls back to the same default
|
||||
the frontend uses (`_INDICATOR_DEFAULT`)."""
|
||||
monkeypatch.setattr(
|
||||
server, "_load_cfg", lambda: {"display": {"tui_status_indicator": "rainbow"}}
|
||||
)
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "config.get", "params": {"key": "indicator"}}
|
||||
)
|
||||
assert resp["result"] == {"value": "kaomoji"}
|
||||
|
||||
|
||||
def test_config_get_indicator_falls_back_when_unset(monkeypatch):
|
||||
monkeypatch.setattr(server, "_load_cfg", lambda: {"display": {}})
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "config.get", "params": {"key": "indicator"}}
|
||||
)
|
||||
assert resp["result"] == {"value": "kaomoji"}
|
||||
|
||||
|
||||
# ── config.set indicator validation ──────────────────────────────────
|
||||
|
||||
|
||||
def test_config_set_indicator_accepts_known_value(monkeypatch):
|
||||
written: dict = {}
|
||||
monkeypatch.setattr(
|
||||
server, "_write_config_key",
|
||||
lambda k, v: written.update({k: v}),
|
||||
)
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "config.set", "params": {"key": "indicator", "value": "EMOJI"}}
|
||||
)
|
||||
assert resp["result"] == {"key": "indicator", "value": "emoji"}
|
||||
assert written == {"display.tui_status_indicator": "emoji"}
|
||||
|
||||
|
||||
def test_config_set_indicator_falsy_non_string_surfaces_in_error(monkeypatch):
|
||||
"""`0` / `False` / `[]` are not valid styles, but the error message
|
||||
must still tell the user what they sent — `value or ""` would have
|
||||
erased them to a blank string."""
|
||||
monkeypatch.setattr(server, "_write_config_key", lambda *a, **k: None)
|
||||
|
||||
for bad in (0, False, []):
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "config.set", "params": {"key": "indicator", "value": bad}}
|
||||
)
|
||||
assert "error" in resp
|
||||
msg = resp["error"]["message"]
|
||||
assert "unknown indicator" in msg
|
||||
# The exact repr varies; `0`/`False` stringify with content,
|
||||
# `[]` becomes an empty list — what matters is the diagnostic
|
||||
# is no longer just `unknown indicator: ` with nothing after.
|
||||
assert msg.split("; ")[0] != "unknown indicator: ''"
|
||||
|
||||
|
||||
def test_config_set_indicator_none_keeps_blank_repr(monkeypatch):
|
||||
"""`None` is the genuine 'no value' case — empty raw is acceptable."""
|
||||
monkeypatch.setattr(server, "_write_config_key", lambda *a, **k: None)
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "config.set", "params": {"key": "indicator", "value": None}}
|
||||
)
|
||||
assert "error" in resp
|
||||
assert "unknown indicator: ''" in resp["error"]["message"]
|
||||
|
||||
@@ -83,6 +83,134 @@ def test_write_json_broken_pipe(server):
|
||||
assert server.write_json({"x": 1}) is False
|
||||
|
||||
|
||||
def test_write_json_closed_stream_returns_false(server):
|
||||
"""ValueError ('I/O on closed file') used to bubble up; treat as gone."""
|
||||
|
||||
class _Closed:
|
||||
def write(self, _): raise ValueError("I/O operation on closed file")
|
||||
def flush(self): raise ValueError("I/O operation on closed file")
|
||||
|
||||
server._real_stdout = _Closed()
|
||||
assert server.write_json({"x": 1}) is False
|
||||
|
||||
|
||||
def test_write_json_unicode_encode_error_re_raises(server):
|
||||
"""A non-UTF-8 stdout encoding raises UnicodeEncodeError (a ValueError
|
||||
subclass). It must NOT be swallowed as 'peer gone' — that would let
|
||||
`entry.py` exit cleanly via the False path and hide the real config
|
||||
bug. We re-raise so the existing crash-log infrastructure records it."""
|
||||
|
||||
class _AsciiOnly:
|
||||
def write(self, line):
|
||||
line.encode("ascii") # raises UnicodeEncodeError on non-ascii
|
||||
def flush(self): pass
|
||||
|
||||
server._real_stdout = _AsciiOnly()
|
||||
with pytest.raises(UnicodeEncodeError):
|
||||
server.write_json({"msg": "héllo"})
|
||||
|
||||
|
||||
def test_write_json_unrelated_value_error_re_raises(server):
|
||||
"""Only ValueError('...closed file...') means peer gone. Other
|
||||
ValueErrors are programming errors and must surface."""
|
||||
|
||||
class _BadValue:
|
||||
def write(self, _): raise ValueError("something else entirely")
|
||||
def flush(self): pass
|
||||
|
||||
server._real_stdout = _BadValue()
|
||||
with pytest.raises(ValueError, match="something else entirely"):
|
||||
server.write_json({"x": 1})
|
||||
|
||||
|
||||
def test_write_json_non_serializable_payload_re_raises(server):
|
||||
"""Non-JSON-safe payloads are programming errors — they must NOT be
|
||||
silently dropped via the False path (which would trigger a clean exit
|
||||
in entry.py and mask the real bug)."""
|
||||
import io
|
||||
|
||||
server._real_stdout = io.StringIO()
|
||||
with pytest.raises(TypeError):
|
||||
server.write_json({"obj": object()})
|
||||
|
||||
|
||||
def test_write_json_peer_gone_oserror_on_flush_returns_false(server):
|
||||
"""A flush that raises a peer-gone OSError (EPIPE) must not strand
|
||||
the lock or crash; it returns False so the dispatcher exits cleanly."""
|
||||
import errno
|
||||
|
||||
written = []
|
||||
|
||||
class _FlushPeerGone:
|
||||
def write(self, line): written.append(line)
|
||||
def flush(self): raise OSError(errno.EPIPE, "broken pipe")
|
||||
|
||||
server._real_stdout = _FlushPeerGone()
|
||||
assert server.write_json({"x": 1}) is False
|
||||
assert written and json.loads(written[0]) == {"x": 1}
|
||||
|
||||
|
||||
def test_write_json_non_peer_gone_oserror_re_raises(server):
|
||||
"""Host I/O failures (ENOSPC, EACCES, EIO …) are NOT peer-gone — they
|
||||
must re-raise so the crash log records them instead of looking like
|
||||
a clean disconnect via the False path."""
|
||||
import errno
|
||||
|
||||
class _DiskFull:
|
||||
def write(self, _): raise OSError(errno.ENOSPC, "no space left")
|
||||
def flush(self): pass
|
||||
|
||||
server._real_stdout = _DiskFull()
|
||||
with pytest.raises(OSError, match="no space"):
|
||||
server.write_json({"x": 1})
|
||||
|
||||
|
||||
def test_write_json_skips_flush_when_disable_flush_true(monkeypatch):
|
||||
"""`StdioTransport` skips flush when `_DISABLE_FLUSH` is true.
|
||||
|
||||
Tests the runtime *behaviour* via direct module-attr patch. The env
|
||||
var → module constant wiring is covered by the dedicated env test
|
||||
below; reloading server.py here would re-register atexit hooks and
|
||||
recreate the worker pool.
|
||||
"""
|
||||
import importlib
|
||||
|
||||
transport_mod = importlib.import_module("tui_gateway.transport")
|
||||
monkeypatch.setattr(transport_mod, "_DISABLE_FLUSH", True)
|
||||
|
||||
flushed = {"count": 0}
|
||||
written = []
|
||||
|
||||
class _Stream:
|
||||
def write(self, line): written.append(line)
|
||||
def flush(self): flushed["count"] += 1
|
||||
|
||||
stream = _Stream()
|
||||
transport = transport_mod.StdioTransport(lambda: stream, threading.Lock())
|
||||
|
||||
assert transport.write({"x": 1}) is True
|
||||
assert flushed["count"] == 0
|
||||
|
||||
|
||||
def test_disable_flush_env_var_actually_wires_to_module_constant(monkeypatch):
|
||||
"""End-to-end: setting `HERMES_TUI_GATEWAY_NO_FLUSH=1` and importing
|
||||
`tui_gateway.transport` fresh actually flips `_DISABLE_FLUSH` true.
|
||||
|
||||
Reloads only the transport module — server.py is untouched so its
|
||||
atexit hooks/worker pool stay intact."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.setenv("HERMES_TUI_GATEWAY_NO_FLUSH", "1")
|
||||
transport_mod = importlib.reload(importlib.import_module("tui_gateway.transport"))
|
||||
|
||||
try:
|
||||
assert transport_mod._DISABLE_FLUSH is True
|
||||
finally:
|
||||
# Restore the env-disabled state so other tests see the default.
|
||||
monkeypatch.delenv("HERMES_TUI_GATEWAY_NO_FLUSH", raising=False)
|
||||
importlib.reload(transport_mod)
|
||||
|
||||
|
||||
# ── _emit ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -164,6 +164,18 @@ HARDLINE_PATTERNS = [
|
||||
(_CMDPOS + r'telinit\s+[06]\b', "telinit 0/6 (shutdown/reboot)"),
|
||||
]
|
||||
|
||||
# Pre-compiled variant used by the hot-path matcher. Building these at module
|
||||
# load eliminates the ~2.6 ms cold-cache re.compile fan-out on the first
|
||||
# terminal() call per process (12 HARDLINE + 47 DANGEROUS patterns, each
|
||||
# potentially evicted from Python's 512-entry ``re._cache`` by unrelated
|
||||
# regex work elsewhere in the agent). DANGEROUS_PATTERNS_COMPILED is built
|
||||
# at the end of this module after DANGEROUS_PATTERNS is defined.
|
||||
_RE_FLAGS = re.IGNORECASE | re.DOTALL
|
||||
HARDLINE_PATTERNS_COMPILED = [
|
||||
(re.compile(pattern, _RE_FLAGS), description)
|
||||
for pattern, description in HARDLINE_PATTERNS
|
||||
]
|
||||
|
||||
|
||||
def detect_hardline_command(command: str) -> tuple:
|
||||
"""Check if a command matches the unconditional hardline blocklist.
|
||||
@@ -172,8 +184,8 @@ def detect_hardline_command(command: str) -> tuple:
|
||||
(is_hardline, description) or (False, None)
|
||||
"""
|
||||
normalized = _normalize_command_for_detection(command).lower()
|
||||
for pattern, description in HARDLINE_PATTERNS:
|
||||
if re.search(pattern, normalized, re.IGNORECASE | re.DOTALL):
|
||||
for pattern_re, description in HARDLINE_PATTERNS_COMPILED:
|
||||
if pattern_re.search(normalized):
|
||||
return (True, description)
|
||||
return (False, None)
|
||||
|
||||
@@ -267,6 +279,13 @@ DANGEROUS_PATTERNS = [
|
||||
]
|
||||
|
||||
|
||||
# Pre-compiled variant (same rationale as HARDLINE_PATTERNS_COMPILED above).
|
||||
DANGEROUS_PATTERNS_COMPILED = [
|
||||
(re.compile(pattern, _RE_FLAGS), description)
|
||||
for pattern, description in DANGEROUS_PATTERNS
|
||||
]
|
||||
|
||||
|
||||
def _legacy_pattern_key(pattern: str) -> str:
|
||||
"""Reproduce the old regex-derived approval key for backwards compatibility."""
|
||||
return pattern.split(r'\b')[1] if r'\b' in pattern else pattern[:20]
|
||||
@@ -319,8 +338,8 @@ def detect_dangerous_command(command: str) -> tuple:
|
||||
(is_dangerous, pattern_key, description) or (False, None, None)
|
||||
"""
|
||||
command_lower = _normalize_command_for_detection(command).lower()
|
||||
for pattern, description in DANGEROUS_PATTERNS:
|
||||
if re.search(pattern, command_lower, re.IGNORECASE | re.DOTALL):
|
||||
for pattern_re, description in DANGEROUS_PATTERNS_COMPILED:
|
||||
if pattern_re.search(command_lower):
|
||||
pattern_key = description
|
||||
return (True, pattern_key, description)
|
||||
return (False, None, None)
|
||||
|
||||
@@ -19,6 +19,7 @@ import importlib
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Callable, Dict, List, Optional, Set
|
||||
|
||||
@@ -97,6 +98,48 @@ class ToolEntry:
|
||||
self.max_result_size_chars = max_result_size_chars
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# check_fn TTL cache
|
||||
#
|
||||
# check_fn callables like tools/terminal_tool.check_terminal_requirements
|
||||
# probe external state (Docker daemon, Modal SDK install, playwright binary
|
||||
# availability). For a long-lived CLI or gateway process, calling them on
|
||||
# every get_definitions() is pure waste — external state changes on human
|
||||
# timescales. Cache results for ~30 s so env-var flips via ``hermes tools``
|
||||
# or live credential file changes propagate within a turn or two without
|
||||
# requiring any explicit invalidation.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CHECK_FN_TTL_SECONDS = 30.0
|
||||
_check_fn_cache: Dict[Callable, tuple[float, bool]] = {}
|
||||
_check_fn_cache_lock = threading.Lock()
|
||||
|
||||
|
||||
def _check_fn_cached(fn: Callable) -> bool:
|
||||
"""Return bool(fn()), TTL-cached across calls. Swallows exceptions as False."""
|
||||
now = time.monotonic()
|
||||
with _check_fn_cache_lock:
|
||||
cached = _check_fn_cache.get(fn)
|
||||
if cached is not None:
|
||||
ts, value = cached
|
||||
if now - ts < _CHECK_FN_TTL_SECONDS:
|
||||
return value
|
||||
try:
|
||||
value = bool(fn())
|
||||
except Exception:
|
||||
value = False
|
||||
with _check_fn_cache_lock:
|
||||
_check_fn_cache[fn] = (now, value)
|
||||
return value
|
||||
|
||||
|
||||
def invalidate_check_fn_cache() -> None:
|
||||
"""Drop all cached ``check_fn`` results. Call after config changes that
|
||||
affect tool availability (e.g. ``hermes tools enable``)."""
|
||||
with _check_fn_cache_lock:
|
||||
_check_fn_cache.clear()
|
||||
|
||||
|
||||
class ToolRegistry:
|
||||
"""Singleton registry that collects tool schemas + handlers from tool files."""
|
||||
|
||||
@@ -108,6 +151,12 @@ class ToolRegistry:
|
||||
# reading tool metadata, so keep mutations serialized and readers on
|
||||
# stable snapshots.
|
||||
self._lock = threading.RLock()
|
||||
# Monotonically-increasing generation counter. Bumped on every
|
||||
# mutation (register / deregister / register_toolset_alias / MCP
|
||||
# refresh). External callers (e.g. get_tool_definitions) can memoize
|
||||
# against it: a cache entry keyed on the generation is valid for as
|
||||
# long as the generation hasn't changed.
|
||||
self._generation: int = 0
|
||||
|
||||
def _snapshot_state(self) -> tuple[List[ToolEntry], Dict[str, Callable]]:
|
||||
"""Return a coherent snapshot of registry entries and toolset checks."""
|
||||
@@ -158,6 +207,7 @@ class ToolRegistry:
|
||||
alias, existing, toolset,
|
||||
)
|
||||
self._toolset_aliases[alias] = toolset
|
||||
self._generation += 1
|
||||
|
||||
def get_registered_toolset_aliases(self) -> Dict[str, str]:
|
||||
"""Return a snapshot of ``{alias: canonical_toolset}`` mappings."""
|
||||
@@ -225,6 +275,7 @@ class ToolRegistry:
|
||||
)
|
||||
if check_fn and toolset not in self._toolset_checks:
|
||||
self._toolset_checks[toolset] = check_fn
|
||||
self._generation += 1
|
||||
|
||||
def deregister(self, name: str) -> None:
|
||||
"""Remove a tool from the registry.
|
||||
@@ -249,6 +300,7 @@ class ToolRegistry:
|
||||
for alias, target in self._toolset_aliases.items()
|
||||
if target != entry.toolset
|
||||
}
|
||||
self._generation += 1
|
||||
logger.debug("Deregistered tool: %s", name)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -259,9 +311,17 @@ class ToolRegistry:
|
||||
"""Return OpenAI-format tool schemas for the requested tool names.
|
||||
|
||||
Only tools whose ``check_fn()`` returns True (or have no check_fn)
|
||||
are included.
|
||||
are included. ``check_fn()`` results are cached for ~30 s via
|
||||
:func:`_check_fn_cached` to amortize repeat probes (check_terminal_
|
||||
requirements probes modal/docker, browser checks probe playwright,
|
||||
etc.); TTL chosen so env-var changes (``hermes tools enable foo``)
|
||||
still take effect in near-real-time without forcing a full cache
|
||||
flush on every call.
|
||||
"""
|
||||
result = []
|
||||
# Per-call cache on top of the 30 s TTL — handles repeat probes of the
|
||||
# same check_fn within one definitions pass without re-reading the
|
||||
# TTL clock.
|
||||
check_results: Dict[Callable, bool] = {}
|
||||
entries_by_name = {entry.name: entry for entry in self._snapshot_entries()}
|
||||
for name in sorted(tool_names):
|
||||
@@ -270,12 +330,7 @@ class ToolRegistry:
|
||||
continue
|
||||
if entry.check_fn:
|
||||
if entry.check_fn not in check_results:
|
||||
try:
|
||||
check_results[entry.check_fn] = bool(entry.check_fn())
|
||||
except Exception:
|
||||
check_results[entry.check_fn] = False
|
||||
if not quiet:
|
||||
logger.debug("Tool %s check raised; skipping", name)
|
||||
check_results[entry.check_fn] = _check_fn_cached(entry.check_fn)
|
||||
if not check_results[entry.check_fn]:
|
||||
if not quiet:
|
||||
logger.debug("Tool %s unavailable (check failed)", name)
|
||||
|
||||
@@ -45,12 +45,47 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import asyncio
|
||||
from typing import List, Dict, Any, Optional
|
||||
from typing import List, Dict, Any, Optional, TYPE_CHECKING
|
||||
import httpx
|
||||
# NOTE: `from firecrawl import Firecrawl` is deliberately NOT at module top —
|
||||
# the SDK pulls ~200 ms of imports (httpcore, firecrawl.v1/v2 type trees) and
|
||||
# we only need it when the backend is actually "firecrawl". See
|
||||
# _get_firecrawl_client() below for the lazy import.
|
||||
# we only need it when the backend is actually "firecrawl". We expose
|
||||
# ``Firecrawl`` as a thin proxy that imports the SDK on first call/
|
||||
# isinstance check, so both (a) the in-module ``Firecrawl(...)`` construction
|
||||
# site in _get_firecrawl_client() works unchanged, and (b) tests using
|
||||
# ``patch("tools.web_tools.Firecrawl", ...)`` keep working.
|
||||
if TYPE_CHECKING:
|
||||
from firecrawl import Firecrawl # noqa: F401 — type hints only
|
||||
|
||||
_FIRECRAWL_CLS_CACHE: Optional[type] = None
|
||||
|
||||
|
||||
def _load_firecrawl_cls() -> type:
|
||||
"""Import and cache ``firecrawl.Firecrawl``."""
|
||||
global _FIRECRAWL_CLS_CACHE
|
||||
if _FIRECRAWL_CLS_CACHE is None:
|
||||
from firecrawl import Firecrawl as _cls
|
||||
_FIRECRAWL_CLS_CACHE = _cls
|
||||
return _FIRECRAWL_CLS_CACHE
|
||||
|
||||
|
||||
class _FirecrawlProxy:
|
||||
"""Module-level proxy that looks like ``firecrawl.Firecrawl`` but imports lazily."""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
return _load_firecrawl_cls()(*args, **kwargs)
|
||||
|
||||
def __instancecheck__(self, obj):
|
||||
return isinstance(obj, _load_firecrawl_cls())
|
||||
|
||||
def __repr__(self):
|
||||
return "<lazy firecrawl.Firecrawl proxy>"
|
||||
|
||||
|
||||
Firecrawl = _FirecrawlProxy()
|
||||
|
||||
from agent.auxiliary_client import (
|
||||
async_call_llm,
|
||||
extract_content_or_reasoning,
|
||||
@@ -239,8 +274,7 @@ def _get_firecrawl_client():
|
||||
if _firecrawl_client is not None and _firecrawl_client_config == client_config:
|
||||
return _firecrawl_client
|
||||
|
||||
# Lazy import — ~200 ms of SDK init, only paid when firecrawl is actually used.
|
||||
from firecrawl import Firecrawl # noqa: E402
|
||||
# Uses the module-level `Firecrawl` name (lazy proxy at module top).
|
||||
_firecrawl_client = Firecrawl(**kwargs)
|
||||
_firecrawl_client_config = client_config
|
||||
return _firecrawl_client
|
||||
|
||||
@@ -29,6 +29,28 @@ def _install_sidecar_publisher() -> None:
|
||||
)
|
||||
|
||||
|
||||
# How long to wait for orderly shutdown (atexit + finalisers) before
|
||||
# falling back to ``os._exit(0)`` so a wedged worker mid-flush can't
|
||||
# strand the process. 1s covers the gateway's own shutdown work
|
||||
# (thread-pool drain + session finalize) on every machine we've
|
||||
# tested; override via ``HERMES_TUI_GATEWAY_SHUTDOWN_GRACE_S`` if a
|
||||
# slower environment needs more headroom (e.g. encrypted disks
|
||||
# flushing checkpoints) and accept that a longer grace also means a
|
||||
# longer wait when shutdown actually deadlocks.
|
||||
_DEFAULT_SHUTDOWN_GRACE_S = 1.0
|
||||
|
||||
|
||||
def _shutdown_grace_seconds() -> float:
|
||||
raw = (os.environ.get("HERMES_TUI_GATEWAY_SHUTDOWN_GRACE_S") or "").strip()
|
||||
if not raw:
|
||||
return _DEFAULT_SHUTDOWN_GRACE_S
|
||||
try:
|
||||
value = float(raw)
|
||||
except ValueError:
|
||||
return _DEFAULT_SHUTDOWN_GRACE_S
|
||||
return value if value > 0 else _DEFAULT_SHUTDOWN_GRACE_S
|
||||
|
||||
|
||||
def _log_signal(signum: int, frame) -> None:
|
||||
"""Capture WHICH thread and WHERE a termination signal hit us.
|
||||
|
||||
@@ -38,6 +60,15 @@ def _log_signal(signum: int, frame) -> None:
|
||||
handler the gateway-exited banner in the TUI has no trace — the
|
||||
crash log never sees a Python exception because the kernel reaps
|
||||
the process before the interpreter runs anything.
|
||||
|
||||
Termination semantics: ``sys.exit(0)`` here used to race the worker
|
||||
pool — a thread holding ``_stdout_lock`` mid-flush would block the
|
||||
interpreter shutdown indefinitely. We now log the stack, give the
|
||||
process the configured shutdown grace
|
||||
(``HERMES_TUI_GATEWAY_SHUTDOWN_GRACE_S``, default
|
||||
``_DEFAULT_SHUTDOWN_GRACE_S``) to drain naturally on a background
|
||||
thread, and fall back to ``os._exit(0)`` so a wedged write/flush
|
||||
can never strand the process.
|
||||
"""
|
||||
name = {
|
||||
signal.SIGPIPE: "SIGPIPE",
|
||||
@@ -62,7 +93,31 @@ def _log_signal(signum: int, frame) -> None:
|
||||
except Exception:
|
||||
pass
|
||||
print(f"[gateway-signal] {name}", file=sys.stderr, flush=True)
|
||||
sys.exit(0)
|
||||
|
||||
import threading as _threading
|
||||
|
||||
def _hard_exit() -> None:
|
||||
# If a worker thread is still mid-flush on a half-closed pipe,
|
||||
# ``sys.exit(0)`` would wait forever for it to drop the GIL on
|
||||
# interpreter shutdown. ``os._exit`` skips atexit handlers but
|
||||
# breaks the deadlock. The crash log + stderr line above are
|
||||
# the forensic trail.
|
||||
os._exit(0)
|
||||
|
||||
timer = _threading.Timer(_shutdown_grace_seconds(), _hard_exit)
|
||||
timer.daemon = True
|
||||
timer.start()
|
||||
|
||||
try:
|
||||
sys.exit(0)
|
||||
except SystemExit:
|
||||
# Re-raise so the main-thread interpreter unwinds and runs
|
||||
# atexit + finalisers inside the grace window. Python signal
|
||||
# handlers always run on the main thread, but a worker thread
|
||||
# holding ``_stdout_lock`` mid-flush can keep that unwind
|
||||
# waiting indefinitely; the daemon timer above is the safety
|
||||
# net for that exact case.
|
||||
raise
|
||||
|
||||
|
||||
# SIGPIPE: ignore, don't exit. The old SIG_DFL killed the process
|
||||
|
||||
@@ -491,6 +491,13 @@ def _normalize_completion_path(path_part: str) -> str:
|
||||
# ── Config I/O ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
# Keep aligned with `INDICATOR_STYLES` / `DEFAULT_INDICATOR_STYLE` in
|
||||
# ``ui-tui/src/app/interfaces.ts`` — both ends validate against the
|
||||
# same shape so `config.get indicator` and the live TUI render agree.
|
||||
_INDICATOR_STYLES: tuple[str, ...] = ("ascii", "emoji", "kaomoji", "unicode")
|
||||
_INDICATOR_DEFAULT = "kaomoji"
|
||||
|
||||
|
||||
def _load_cfg() -> dict:
|
||||
global _cfg_cache, _cfg_mtime, _cfg_path
|
||||
try:
|
||||
@@ -683,6 +690,21 @@ def _coerce_statusbar(raw) -> str:
|
||||
return "top"
|
||||
|
||||
|
||||
def _display_mouse_tracking(display: dict) -> bool:
|
||||
"""Return canonical display.mouse_tracking with legacy tui_mouse fallback."""
|
||||
if not isinstance(display, dict):
|
||||
return True
|
||||
if "mouse_tracking" in display:
|
||||
raw = display.get("mouse_tracking")
|
||||
else:
|
||||
raw = display.get("tui_mouse", True)
|
||||
if raw is False or raw == 0:
|
||||
return False
|
||||
if isinstance(raw, str):
|
||||
return raw.strip().lower() not in {"0", "false", "no", "off"}
|
||||
return True
|
||||
|
||||
|
||||
def _load_reasoning_config() -> dict | None:
|
||||
from hermes_constants import parse_reasoning_effort
|
||||
|
||||
@@ -3165,12 +3187,9 @@ def _(rid, params: dict) -> dict:
|
||||
|
||||
if key == "mouse":
|
||||
raw = str(value or "").strip().lower()
|
||||
display = (
|
||||
_load_cfg().get("display")
|
||||
if isinstance(_load_cfg().get("display"), dict)
|
||||
else {}
|
||||
)
|
||||
current = bool(display.get("tui_mouse", True))
|
||||
cfg = _load_cfg()
|
||||
display = cfg.get("display") if isinstance(cfg.get("display"), dict) else {}
|
||||
current = _display_mouse_tracking(display)
|
||||
|
||||
if raw in ("", "toggle"):
|
||||
nv = not current
|
||||
@@ -3181,9 +3200,22 @@ def _(rid, params: dict) -> dict:
|
||||
else:
|
||||
return _err(rid, 4002, f"unknown mouse value: {value}")
|
||||
|
||||
_write_config_key("display.tui_mouse", nv)
|
||||
_write_config_key("display.mouse_tracking", nv)
|
||||
return _ok(rid, {"key": key, "value": "on" if nv else "off"})
|
||||
|
||||
if key == "indicator":
|
||||
# Use an explicit None check rather than `value or ""` so falsy
|
||||
# non-string inputs (0, False, []) still surface as themselves
|
||||
# in the error message instead of looking like a blank value.
|
||||
raw = ("" if value is None else str(value)).strip().lower()
|
||||
if raw not in _INDICATOR_STYLES:
|
||||
return _err(
|
||||
rid, 4002,
|
||||
f"unknown indicator: {raw!r}; pick one of {'|'.join(_INDICATOR_STYLES)}",
|
||||
)
|
||||
_write_config_key("display.tui_status_indicator", raw)
|
||||
return _ok(rid, {"key": key, "value": raw})
|
||||
|
||||
if key in ("prompt", "personality", "skin"):
|
||||
try:
|
||||
cfg = _load_cfg()
|
||||
@@ -3254,6 +3286,18 @@ def _(rid, params: dict) -> dict:
|
||||
return _ok(
|
||||
rid, {"value": (_load_cfg().get("display") or {}).get("skin", "default")}
|
||||
)
|
||||
if key == "indicator":
|
||||
# Normalize so a hand-edited config.yaml with stray casing or
|
||||
# an unknown value reads back the SAME value the TUI actually
|
||||
# rendered (frontend's `normalizeIndicatorStyle` falls back to
|
||||
# `_INDICATOR_DEFAULT` for the same inputs). Otherwise
|
||||
# `/indicator` would print one thing while the UI shows another.
|
||||
raw = (_load_cfg().get("display") or {}).get("tui_status_indicator", "")
|
||||
norm = str(raw).strip().lower()
|
||||
return _ok(
|
||||
rid,
|
||||
{"value": norm if norm in _INDICATOR_STYLES else _INDICATOR_DEFAULT},
|
||||
)
|
||||
if key == "personality":
|
||||
return _ok(
|
||||
rid,
|
||||
@@ -3329,7 +3373,7 @@ def _(rid, params: dict) -> dict:
|
||||
return _ok(rid, {"value": _coerce_statusbar(raw)})
|
||||
if key == "mouse":
|
||||
display = _load_cfg().get("display")
|
||||
on = display.get("tui_mouse", True) if isinstance(display, dict) else True
|
||||
on = _display_mouse_tracking(display)
|
||||
return _ok(rid, {"value": "on" if on else "off"})
|
||||
if key == "mtime":
|
||||
cfg_path = _hermes_home / "config.yaml"
|
||||
|
||||
@@ -23,10 +23,45 @@ the stream lazily through a callback.
|
||||
from __future__ import annotations
|
||||
|
||||
import contextvars
|
||||
import errno
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from typing import Any, Callable, Optional, Protocol, runtime_checkable
|
||||
|
||||
# Errno values that mean "the peer is gone" rather than "the host has a
|
||||
# real I/O problem". Anything outside this set re-raises so it surfaces
|
||||
# in the crash log instead of looking like a clean disconnect.
|
||||
_PEER_GONE_ERRNOS = frozenset({
|
||||
errno.EPIPE, # write to closed pipe (POSIX)
|
||||
errno.ECONNRESET, # peer reset the connection
|
||||
errno.EBADF, # fd closed under us
|
||||
errno.ESHUTDOWN, # transport endpoint shut down
|
||||
getattr(errno, "WSAECONNRESET", -1), # win32 mapping (no-op on POSIX)
|
||||
getattr(errno, "WSAESHUTDOWN", -1),
|
||||
} - {-1})
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Optional knob: when true, StdioTransport does not call ``stream.flush``
|
||||
# after writing. Use this on environments where a half-closed pipe (TUI
|
||||
# Node parent quit while the gateway is still emitting events) makes
|
||||
# flush block long enough to starve the rest of the worker pool.
|
||||
#
|
||||
# IMPORTANT: Python text stdout is fully buffered when attached to a
|
||||
# pipe (the TUI case), so this knob ONLY makes sense when the gateway
|
||||
# is launched with ``-u`` or ``PYTHONUNBUFFERED=1``. Without one of
|
||||
# those, JSON-RPC frames will accumulate in the buffer and the TUI
|
||||
# will hang waiting for ``gateway.ready``. Default stays off so the
|
||||
# existing flush-after-write behaviour is unchanged.
|
||||
_DISABLE_FLUSH = (os.environ.get("HERMES_TUI_GATEWAY_NO_FLUSH", "") or "").strip().lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
}
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class Transport(Protocol):
|
||||
@@ -77,15 +112,72 @@ class StdioTransport:
|
||||
self._lock = lock
|
||||
|
||||
def write(self, obj: dict) -> bool:
|
||||
"""Return ``True`` on success, ``False`` ONLY when the peer is gone.
|
||||
|
||||
Returning ``False`` is the dispatcher's "broken stdout pipe" signal
|
||||
— ``entry.py`` calls ``sys.exit(0)`` when ``write_json`` reports
|
||||
``False``. So programming errors (non-JSON-safe payloads, encoding
|
||||
misconfig, unexpected ValueErrors, host I/O bugs like ENOSPC) MUST
|
||||
NOT return ``False``, otherwise a real bug looks like a clean
|
||||
disconnect and is harder to diagnose. Those re-raise so the
|
||||
existing crash-log infrastructure records the traceback.
|
||||
|
||||
Peer-gone branches:
|
||||
* ``BrokenPipeError``
|
||||
* ``ValueError("...closed file...")``
|
||||
* ``OSError`` whose errno is in :data:`_PEER_GONE_ERRNOS`
|
||||
(EPIPE / ECONNRESET / EBADF / ESHUTDOWN; plus WSA mappings
|
||||
on Windows). Other OSError errnos (ENOSPC, EACCES, ...) are
|
||||
real host problems and re-raise.
|
||||
"""
|
||||
# Serialization is OUTSIDE the lock so a large payload can't
|
||||
# block other threads emitting their own frames. A non-JSON-safe
|
||||
# payload is a programming error: re-raise so the crash log
|
||||
# captures it instead of silently exiting via the False path.
|
||||
line = json.dumps(obj, ensure_ascii=False) + "\n"
|
||||
try:
|
||||
with self._lock:
|
||||
stream = self._stream_getter()
|
||||
|
||||
with self._lock:
|
||||
stream = self._stream_getter()
|
||||
try:
|
||||
stream.write(line)
|
||||
stream.flush()
|
||||
return True
|
||||
except BrokenPipeError:
|
||||
return False
|
||||
except BrokenPipeError:
|
||||
return False
|
||||
except ValueError as e:
|
||||
# ValueError("I/O operation on closed file") is the
|
||||
# ONLY ValueError that means "peer gone". Anything
|
||||
# else — including UnicodeEncodeError, which is a
|
||||
# ValueError subclass for misconfigured locales —
|
||||
# is a real bug; re-raise so it surfaces in the crash log.
|
||||
if isinstance(e, UnicodeEncodeError) or "closed file" not in str(e):
|
||||
raise
|
||||
return False
|
||||
except OSError as e:
|
||||
if e.errno not in _PEER_GONE_ERRNOS:
|
||||
raise
|
||||
logger.debug("StdioTransport write peer gone: %s", e)
|
||||
return False
|
||||
|
||||
# A flush that *raises* with a peer-gone errno means the
|
||||
# dispatcher should exit cleanly. A flush that *hangs* on
|
||||
# a half-closed pipe holds the lock until it returns — see
|
||||
# ``_DISABLE_FLUSH`` for the "skip flush entirely" escape
|
||||
# hatch.
|
||||
if not _DISABLE_FLUSH:
|
||||
try:
|
||||
stream.flush()
|
||||
except BrokenPipeError:
|
||||
return False
|
||||
except ValueError as e:
|
||||
if isinstance(e, UnicodeEncodeError) or "closed file" not in str(e):
|
||||
raise
|
||||
return False
|
||||
except OSError as e:
|
||||
if e.errno not in _PEER_GONE_ERRNOS:
|
||||
raise
|
||||
logger.debug("StdioTransport flush peer gone: %s", e)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def close(self) -> None:
|
||||
return None
|
||||
|
||||
2
ui-tui/packages/hermes-ink/index.d.ts
vendored
2
ui-tui/packages/hermes-ink/index.d.ts
vendored
@@ -30,7 +30,7 @@ export { useTerminalFocus } from './src/ink/hooks/use-terminal-focus.ts'
|
||||
export { useTerminalTitle } from './src/ink/hooks/use-terminal-title.ts'
|
||||
export { useTerminalViewport } from './src/ink/hooks/use-terminal-viewport.ts'
|
||||
export { default as measureElement } from './src/ink/measure-element.ts'
|
||||
export { createRoot, default as render, forceRedraw, renderSync } from './src/ink/root.ts'
|
||||
export { createRoot, forceRedraw, default as render, renderSync } from './src/ink/root.ts'
|
||||
export type { Instance, RenderOptions, Root } from './src/ink/root.ts'
|
||||
export { stringWidth } from './src/ink/stringWidth.ts'
|
||||
export { default as TextInput, UncontrolledTextInput } from 'ink-text-input'
|
||||
|
||||
@@ -23,7 +23,7 @@ export { useTerminalTitle } from './ink/hooks/use-terminal-title.js'
|
||||
export { useTerminalViewport } from './ink/hooks/use-terminal-viewport.js'
|
||||
export { default as measureElement } from './ink/measure-element.js'
|
||||
export { scrollFastPathStats, type ScrollFastPathStats } from './ink/render-node-to-output.js'
|
||||
export { createRoot, default as render, forceRedraw, renderSync } from './ink/root.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 { default as TextInput, UncontrolledTextInput } from 'ink-text-input'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { PureComponent, type ReactNode } from 'react'
|
||||
import { PureComponent, type ReactNode } from 'react'
|
||||
|
||||
import { updateLastInteractionTime } from '../../bootstrap/state.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
@@ -316,8 +316,10 @@ export default class App extends PureComponent<Props, State> {
|
||||
// Clear the timer reference
|
||||
this.incompleteEscapeTimer = null
|
||||
|
||||
// Only proceed if we have incomplete sequences
|
||||
if (!this.keyParseState.incomplete) {
|
||||
// Only proceed if we have an incomplete escape sequence or an unterminated
|
||||
// bracketed paste. Missing paste-end markers otherwise leave every later
|
||||
// keystroke trapped in the paste buffer.
|
||||
if (!this.keyParseState.incomplete && this.keyParseState.mode !== 'IN_PASTE') {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -330,13 +332,16 @@ export default class App extends PureComponent<Props, State> {
|
||||
// drain stdin next and clear this timer. Prevents both the spurious
|
||||
// Escape key and the lost scroll event.
|
||||
if (this.props.stdin.readableLength > 0) {
|
||||
this.incompleteEscapeTimer = setTimeout(this.flushIncomplete, this.NORMAL_TIMEOUT)
|
||||
this.incompleteEscapeTimer = setTimeout(
|
||||
this.flushIncomplete,
|
||||
this.keyParseState.mode === 'IN_PASTE' ? this.PASTE_TIMEOUT : this.NORMAL_TIMEOUT
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Process incomplete as a flush operation (input=null)
|
||||
// This reuses all existing parsing logic
|
||||
// Process incomplete/paste state as a flush operation (input=null).
|
||||
// This reuses all existing parsing logic.
|
||||
this.processInput(null)
|
||||
}
|
||||
|
||||
@@ -355,8 +360,10 @@ export default class App extends PureComponent<Props, State> {
|
||||
reconciler.discreteUpdates(processKeysInBatch, this, keys, undefined, undefined)
|
||||
}
|
||||
|
||||
// If we have incomplete escape sequences, set a timer to flush them
|
||||
if (this.keyParseState.incomplete) {
|
||||
// If we have incomplete escape sequences or an unterminated paste, set a
|
||||
// timer to flush/reset them. Paste starts are complete CSI sequences, so
|
||||
// checking only `incomplete` would never arm the watchdog.
|
||||
if (this.keyParseState.incomplete || this.keyParseState.mode === 'IN_PASTE') {
|
||||
// Cancel any existing timer first
|
||||
if (this.incompleteEscapeTimer) {
|
||||
clearTimeout(this.incompleteEscapeTimer)
|
||||
|
||||
@@ -39,6 +39,15 @@ describe('enhanced keyboard modifier parsing', () => {
|
||||
expect(event.key.super).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves forwarded VS Code/Cursor Cmd+C copy sequence as ctrl+super+c', () => {
|
||||
const parsed = parseOne('\u001b[99;13u')
|
||||
const event = new InputEvent(parsed)
|
||||
|
||||
expect(parsed.name).toBe('c')
|
||||
expect(event.key.ctrl).toBe(true)
|
||||
expect(event.key.super).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves Cmd on word-delete and word-navigation sequences', () => {
|
||||
const backspace = new InputEvent(parseOne('\u001b[127;9u'))
|
||||
const left = new InputEvent(parseOne('\u001b[1;9D'))
|
||||
|
||||
@@ -35,6 +35,8 @@ export function useSelection(): {
|
||||
* replaces the old SGR-7 inverse so syntax highlighting stays readable
|
||||
* under selection). Call once on mount + whenever theme changes. */
|
||||
setSelectionBgColor: (color: string) => void
|
||||
/** Monotonic counter incremented on every selection mutation. */
|
||||
version: () => number
|
||||
} {
|
||||
// Look up the Ink instance via stdout — same pattern as instances map.
|
||||
// StdinContext is available (it's always provided), and the Ink instance
|
||||
@@ -58,7 +60,8 @@ export function useSelection(): {
|
||||
shiftSelection: () => {},
|
||||
moveFocus: () => {},
|
||||
captureScrolledRows: () => {},
|
||||
setSelectionBgColor: () => {}
|
||||
setSelectionBgColor: () => {},
|
||||
version: () => 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +76,8 @@ export function useSelection(): {
|
||||
shiftSelection: (dRow, minRow, maxRow) => ink.shiftSelectionForScroll(dRow, minRow, maxRow),
|
||||
moveFocus: (move: FocusMove) => ink.moveSelectionFocus(move),
|
||||
captureScrolledRows: (firstRow, lastRow, side) => ink.captureScrolledRows(firstRow, lastRow, side),
|
||||
setSelectionBgColor: (color: string) => ink.setSelectionBgColor(color)
|
||||
setSelectionBgColor: (color: string) => ink.setSelectionBgColor(color),
|
||||
version: () => ink.getSelectionVersion()
|
||||
}
|
||||
}, [ink])
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
hasSelection,
|
||||
moveFocus,
|
||||
selectionBounds,
|
||||
selectionSignature,
|
||||
type SelectionState,
|
||||
selectLineAt,
|
||||
selectWordAt,
|
||||
@@ -213,7 +214,8 @@ export default class Ink {
|
||||
// Fired alongside the terminal repaint whenever the selection mutates
|
||||
// so UI (e.g. footer hints) can react to selection appearing/clearing.
|
||||
private readonly selectionListeners = new Set<() => void>()
|
||||
private selectionWasActive = false
|
||||
private selectionVersion = 0
|
||||
private lastSelectionSignature = ''
|
||||
// DOM nodes currently under the pointer (mode-1003 motion). Held here
|
||||
// so App.tsx's handleMouseEvent is stateless — dispatchHover diffs
|
||||
// against this set and mutates it in place.
|
||||
@@ -1661,9 +1663,16 @@ export default class Ink {
|
||||
return hasSelection(this.selection)
|
||||
}
|
||||
|
||||
getSelectionVersion(): number {
|
||||
return this.selectionVersion
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to selection state changes. Fires whenever the selection
|
||||
* is started, updated, cleared, or copied. Returns an unsubscribe fn.
|
||||
* mutates — anchor/focus moves, drag updates, programmatic clears.
|
||||
* Does NOT fire on `copySelectionNoClear()` (no mutation, no notify),
|
||||
* which is why version-based subscribers don't risk re-entrant copies.
|
||||
* Returns an unsubscribe fn.
|
||||
*/
|
||||
subscribeToSelectionChange(cb: () => void): () => void {
|
||||
this.selectionListeners.add(cb)
|
||||
@@ -1673,14 +1682,18 @@ export default class Ink {
|
||||
private notifySelectionChange(): void {
|
||||
this.scheduleRender()
|
||||
|
||||
const active = hasSelection(this.selection)
|
||||
// Only bump version when the selection range actually mutated.
|
||||
// Listeners still fire unconditionally — useHasSelection() snapshots
|
||||
// through React, which dedupes via Object.is on the boolean value.
|
||||
const sig = selectionSignature(this.selection)
|
||||
|
||||
if (active !== this.selectionWasActive) {
|
||||
this.selectionWasActive = active
|
||||
if (sig !== this.lastSelectionSignature) {
|
||||
this.lastSelectionSignature = sig
|
||||
this.selectionVersion += 1
|
||||
}
|
||||
|
||||
for (const cb of this.selectionListeners) {
|
||||
cb()
|
||||
}
|
||||
for (const cb of this.selectionListeners) {
|
||||
cb()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
41
ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts
Normal file
41
ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { INITIAL_STATE, parseMultipleKeypresses } from './parse-keypress.js'
|
||||
import { PASTE_END, PASTE_START } from './termio/csi.js'
|
||||
|
||||
describe('parseMultipleKeypresses bracketed paste recovery', () => {
|
||||
it('emits empty bracketed pastes when the terminal sends both markers', () => {
|
||||
const [keys, state] = parseMultipleKeypresses(INITIAL_STATE, PASTE_START + PASTE_END)
|
||||
|
||||
expect(keys).toHaveLength(1)
|
||||
expect(keys[0]).toMatchObject({ isPasted: true, raw: '' })
|
||||
expect(state.mode).toBe('NORMAL')
|
||||
})
|
||||
|
||||
it('flushes unterminated paste content back to normal input mode', () => {
|
||||
const [pendingKeys, pendingState] = parseMultipleKeypresses(INITIAL_STATE, PASTE_START + 'hello')
|
||||
|
||||
expect(pendingKeys).toEqual([])
|
||||
expect(pendingState.mode).toBe('IN_PASTE')
|
||||
|
||||
const [keys, state] = parseMultipleKeypresses(pendingState, null)
|
||||
|
||||
expect(keys).toHaveLength(1)
|
||||
expect(keys[0]).toMatchObject({ isPasted: true, raw: 'hello' })
|
||||
expect(state.mode).toBe('NORMAL')
|
||||
expect(state.pasteBuffer).toBe('')
|
||||
})
|
||||
|
||||
it('resets an empty unterminated paste start instead of staying stuck', () => {
|
||||
const [pendingKeys, pendingState] = parseMultipleKeypresses(INITIAL_STATE, PASTE_START)
|
||||
|
||||
expect(pendingKeys).toEqual([])
|
||||
expect(pendingState.mode).toBe('IN_PASTE')
|
||||
|
||||
const [keys, state] = parseMultipleKeypresses(pendingState, null)
|
||||
|
||||
expect(keys).toEqual([])
|
||||
expect(state.mode).toBe('NORMAL')
|
||||
expect(state.pasteBuffer).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -288,9 +288,14 @@ export function parseMultipleKeypresses(
|
||||
}
|
||||
}
|
||||
|
||||
// If flushing and still in paste mode, emit what we have
|
||||
if (isFlush && inPaste && pasteBuffer) {
|
||||
keys.push(createPasteKey(pasteBuffer))
|
||||
// If a terminal drops the paste-end marker, the App watchdog flushes the
|
||||
// partial paste and returns to normal input instead of swallowing all future
|
||||
// keystrokes as paste content.
|
||||
if (isFlush && inPaste) {
|
||||
if (pasteBuffer) {
|
||||
keys.push(createPasteKey(pasteBuffer))
|
||||
}
|
||||
|
||||
inPaste = false
|
||||
pasteBuffer = ''
|
||||
}
|
||||
|
||||
@@ -75,11 +75,13 @@ export type Root = {
|
||||
|
||||
export const forceRedraw = (stdout: NodeJS.WriteStream = process.stdout): boolean => {
|
||||
const instance = instances.get(stdout)
|
||||
|
||||
if (!instance) {
|
||||
return false
|
||||
}
|
||||
|
||||
instance.forceRedraw()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -799,6 +799,20 @@ export function hasSelection(s: SelectionState): boolean {
|
||||
return s.anchor !== null && s.focus !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable fingerprint of the user-visible selection state. Used by Ink
|
||||
* to skip incrementing the mutation counter when notifySelectionChange()
|
||||
* fires without an actual change to anchor/focus/isDragging — protects
|
||||
* version-based subscribers (copy-on-select) from re-running for the
|
||||
* same stable selection.
|
||||
*/
|
||||
export function selectionSignature(s: SelectionState): string {
|
||||
const a = s.anchor ? `${s.anchor.row},${s.anchor.col}` : 'null'
|
||||
const f = s.focus ? `${s.focus.row},${s.focus.col}` : 'null'
|
||||
|
||||
return `${a}|${f}|${s.isDragging ? 1 : 0}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalized selection bounds: start is always before end in reading order.
|
||||
* Returns null if no active selection.
|
||||
|
||||
@@ -714,9 +714,7 @@ describe('createGatewayEventHandler', () => {
|
||||
} as any)
|
||||
|
||||
// Pre-interrupt todos should land in turn state.
|
||||
expect(getTurnState().todos).toEqual([
|
||||
{ content: 'pre-interrupt', id: 'todo-1', status: 'pending' }
|
||||
])
|
||||
expect(getTurnState().todos).toEqual([{ content: 'pre-interrupt', id: 'todo-1', status: 'pending' }])
|
||||
|
||||
turnController.interruptTurn({
|
||||
appendMessage: (msg: Msg) => appended.push(msg),
|
||||
|
||||
@@ -195,7 +195,8 @@ describe('createSlashHandler', () => {
|
||||
['/reload-mcp', 'reload.mcp', { session_id: null }],
|
||||
['/stop', 'process.stop', {}],
|
||||
['/fast status', 'config.get', { key: 'fast', session_id: null }],
|
||||
['/busy status', 'config.get', { key: 'busy' }]
|
||||
['/busy status', 'config.get', { key: 'busy' }],
|
||||
['/indicator', 'config.get', { key: 'indicator' }]
|
||||
])('routes %s through native RPC (no slash worker)', (command, method, params) => {
|
||||
const rpc = vi.fn(() => Promise.resolve({}))
|
||||
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
|
||||
@@ -215,6 +216,24 @@ describe('createSlashHandler', () => {
|
||||
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hot-swaps the live indicator when /indicator <style> succeeds', async () => {
|
||||
const rpc = vi.fn(() => Promise.resolve({ value: 'emoji' }))
|
||||
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
|
||||
|
||||
expect(createSlashHandler(ctx)('/indicator emoji')).toBe(true)
|
||||
expect(rpc).toHaveBeenCalledWith('config.set', { key: 'indicator', value: 'emoji' })
|
||||
await vi.waitFor(() => expect(getUiState().indicatorStyle).toBe('emoji'))
|
||||
})
|
||||
|
||||
it('rejects unknown indicator styles before hitting the gateway', () => {
|
||||
const rpc = vi.fn(() => Promise.resolve({}))
|
||||
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
|
||||
|
||||
expect(createSlashHandler(ctx)('/indicator sparkle')).toBe(true)
|
||||
expect(rpc).not.toHaveBeenCalled()
|
||||
expect(ctx.transcript.sys).toHaveBeenCalledWith('usage: /indicator [ascii|emoji|kaomoji|unicode]')
|
||||
})
|
||||
|
||||
it('drops stale slash.exec output after a newer slash', async () => {
|
||||
let resolveLate: (v: { output?: string }) => void
|
||||
let slashExecCalls = 0
|
||||
|
||||
64
ui-tui/src/__tests__/forceTruecolor.test.ts
Normal file
64
ui-tui/src/__tests__/forceTruecolor.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const ENV_KEYS = ['COLORTERM', 'FORCE_COLOR', 'HERMES_TUI_TRUECOLOR', 'NO_COLOR'] as const
|
||||
|
||||
async function withCleanEnv(setup: () => void, body: () => Promise<void>) {
|
||||
const saved: Record<string, string | undefined> = {}
|
||||
|
||||
for (const k of ENV_KEYS) {
|
||||
saved[k] = process.env[k]
|
||||
delete process.env[k]
|
||||
}
|
||||
|
||||
try {
|
||||
setup()
|
||||
await body()
|
||||
} finally {
|
||||
for (const k of ENV_KEYS) {
|
||||
if (saved[k] === undefined) {
|
||||
delete process.env[k]
|
||||
} else {
|
||||
process.env[k] = saved[k]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('forceTruecolor', () => {
|
||||
it('sets COLORTERM=truecolor and FORCE_COLOR=3 when unset', async () => {
|
||||
await withCleanEnv(
|
||||
() => {},
|
||||
async () => {
|
||||
await import('../lib/forceTruecolor.js?t=' + Date.now())
|
||||
expect(process.env.COLORTERM).toBe('truecolor')
|
||||
expect(process.env.FORCE_COLOR).toBe('3')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('respects HERMES_TUI_TRUECOLOR=0 opt-out', async () => {
|
||||
await withCleanEnv(
|
||||
() => {
|
||||
process.env.HERMES_TUI_TRUECOLOR = '0'
|
||||
},
|
||||
async () => {
|
||||
await import('../lib/forceTruecolor.js?t=optout-' + Date.now())
|
||||
expect(process.env.COLORTERM).toBeUndefined()
|
||||
expect(process.env.FORCE_COLOR).toBeUndefined()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('respects NO_COLOR', async () => {
|
||||
await withCleanEnv(
|
||||
() => {
|
||||
process.env.NO_COLOR = '1'
|
||||
},
|
||||
async () => {
|
||||
await import('../lib/forceTruecolor.js?t=no-color-' + Date.now())
|
||||
expect(process.env.COLORTERM).toBeUndefined()
|
||||
expect(process.env.FORCE_COLOR).toBeUndefined()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -51,6 +51,12 @@ describe('isCopyShortcut', () => {
|
||||
|
||||
expect(isCopyShortcut({ ctrl: false, meta: true, super: false }, 'c', {})).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts the VS Code/Cursor forwarded Cmd+C copy sequence on macOS', async () => {
|
||||
const { isCopyShortcut } = await importPlatform('darwin')
|
||||
|
||||
expect(isCopyShortcut({ ctrl: true, meta: false, super: true }, 'c', {})).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isVoiceToggleKey', () => {
|
||||
|
||||
@@ -74,6 +74,6 @@ describe('streaming theme assumption', () => {
|
||||
// Sanity that the theme we pass doesn't change shape. Component import
|
||||
// already happens above — this is a smoke test that the module graph
|
||||
// for streamingMarkdown wires up without cycles.
|
||||
expect(DEFAULT_THEME.color.amber).toBeTruthy()
|
||||
expect(DEFAULT_THEME.color.accent).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,16 +19,16 @@ describe('syntax highlighter', () => {
|
||||
it('paints a whole-line comment dim', () => {
|
||||
const tokens = highlightLine('// hello', 'ts', t)
|
||||
|
||||
expect(tokens).toEqual([[t.color.dim, '// hello']])
|
||||
expect(tokens).toEqual([[t.color.muted, '// hello']])
|
||||
})
|
||||
|
||||
it('paints keywords, strings, and numbers in a ts line', () => {
|
||||
const tokens = highlightLine(`const x = 'hi' + 42`, 'ts', t)
|
||||
const colors = tokens.map(tok => tok[0])
|
||||
|
||||
expect(colors).toContain(t.color.bronze) // const
|
||||
expect(colors).toContain(t.color.amber) // 'hi'
|
||||
expect(colors).toContain(t.color.cornsilk) // 42
|
||||
expect(colors).toContain(t.color.border) // const
|
||||
expect(colors).toContain(t.color.accent) // 'hi'
|
||||
expect(colors).toContain(t.color.text) // 42
|
||||
})
|
||||
|
||||
it('falls through unchanged for unknown langs', () => {
|
||||
@@ -40,6 +40,6 @@ describe('syntax highlighter', () => {
|
||||
it('treats `#` as a python comment, not a selector', () => {
|
||||
const tokens = highlightLine('# comment', 'py', t)
|
||||
|
||||
expect(tokens).toEqual([[t.color.dim, '# comment']])
|
||||
expect(tokens).toEqual([[t.color.muted, '# comment']])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -28,6 +28,12 @@ describe('terminalParityHints', () => {
|
||||
it('suppresses IDE setup hint when keybindings are already configured', async () => {
|
||||
const readFile = vi.fn().mockResolvedValue(
|
||||
JSON.stringify([
|
||||
{
|
||||
key: 'cmd+c',
|
||||
command: 'workbench.action.terminal.sendSequence',
|
||||
when: 'terminalFocus && terminalTextSelected',
|
||||
args: { text: '\u001b[99;13u' }
|
||||
},
|
||||
{
|
||||
key: 'shift+enter',
|
||||
command: 'workbench.action.terminal.sendSequence',
|
||||
|
||||
@@ -79,11 +79,34 @@ describe('configureTerminalKeybindings', () => {
|
||||
expect(writeFile).toHaveBeenCalledTimes(1)
|
||||
expect(copyFile).not.toHaveBeenCalled() // no existing file to back up
|
||||
const written = writeFile.mock.calls[0]?.[1] as string
|
||||
expect(written).toContain('cmd+c')
|
||||
expect(written).toContain('terminalTextSelected')
|
||||
expect(written).toContain('\\u001b[99;13u')
|
||||
expect(written).toContain('shift+enter')
|
||||
expect(written).toContain('cmd+enter')
|
||||
expect(written).toContain('cmd+z')
|
||||
})
|
||||
|
||||
it('only adds the Cmd+C forwarding binding on macOS', async () => {
|
||||
const mkdir = vi.fn().mockResolvedValue(undefined)
|
||||
const readFile = vi.fn().mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
|
||||
const writeFile = vi.fn().mockResolvedValue(undefined)
|
||||
const copyFile = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const result = await configureTerminalKeybindings('vscode', {
|
||||
fileOps: { copyFile, mkdir, readFile, writeFile },
|
||||
homeDir: '/home/me',
|
||||
platform: 'linux'
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const written = writeFile.mock.calls[0]?.[1] as string
|
||||
expect(written).not.toContain('cmd+c')
|
||||
expect(written).not.toContain('terminalTextSelected')
|
||||
expect(written).not.toContain('\\u001b[99;13u')
|
||||
expect(written).toContain('shift+enter')
|
||||
})
|
||||
|
||||
it('reports conflicts without overwriting existing bindings', async () => {
|
||||
const mkdir = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
@@ -113,6 +136,126 @@ describe('configureTerminalKeybindings', () => {
|
||||
expect(copyFile).not.toHaveBeenCalled() // no backup when not writing
|
||||
})
|
||||
|
||||
it('flags a global (when-less) binding on the same key as a conflict', async () => {
|
||||
// A user's keybindings.json `cmd+c` with no `when` clause is global —
|
||||
// it overlaps any context, including our terminal scope. We must NOT
|
||||
// silently add a terminal-scoped cmd+c that would shadow it.
|
||||
const mkdir = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const readFile = vi.fn().mockResolvedValue(
|
||||
JSON.stringify([
|
||||
{
|
||||
key: 'cmd+c',
|
||||
command: 'myExtension.smartCopy'
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const writeFile = vi.fn().mockResolvedValue(undefined)
|
||||
const copyFile = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const result = await configureTerminalKeybindings('vscode', {
|
||||
fileOps: { copyFile, mkdir, readFile, writeFile },
|
||||
homeDir: '/Users/me',
|
||||
platform: 'darwin'
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.message).toContain('cmd+c')
|
||||
expect(writeFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('flags an overlapping terminal-context binding as a conflict', async () => {
|
||||
// Existing `cmd+c` scoped to plain `terminalFocus` overlaps with our
|
||||
// `terminalFocus && terminalTextSelected` — both fire when the
|
||||
// terminal is focused with text selected, so the existing binding
|
||||
// would shadow ours. Treat as a conflict even though the strings
|
||||
// aren't identical.
|
||||
const mkdir = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const readFile = vi.fn().mockResolvedValue(
|
||||
JSON.stringify([
|
||||
{
|
||||
key: 'cmd+c',
|
||||
command: 'workbench.action.terminal.copySelection',
|
||||
when: 'terminalFocus'
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const writeFile = vi.fn().mockResolvedValue(undefined)
|
||||
const copyFile = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const result = await configureTerminalKeybindings('vscode', {
|
||||
fileOps: { copyFile, mkdir, readFile, writeFile },
|
||||
homeDir: '/Users/me',
|
||||
platform: 'darwin'
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.message).toContain('cmd+c')
|
||||
expect(writeFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not flag a negated terminalTextSelected binding as a conflict', async () => {
|
||||
// A binding scoped to "terminal focused but no selected text" is
|
||||
// logically disjoint from our copy-forwarding binding, which requires
|
||||
// terminalTextSelected.
|
||||
const mkdir = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const readFile = vi.fn().mockResolvedValue(
|
||||
JSON.stringify([
|
||||
{
|
||||
key: 'cmd+c',
|
||||
command: 'workbench.action.terminal.sendSequence',
|
||||
when: 'terminalFocus && !terminalTextSelected',
|
||||
args: { text: '\u0003' }
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const writeFile = vi.fn().mockResolvedValue(undefined)
|
||||
const copyFile = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const result = await configureTerminalKeybindings('vscode', {
|
||||
fileOps: { copyFile, mkdir, readFile, writeFile },
|
||||
homeDir: '/Users/me',
|
||||
platform: 'darwin'
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(writeFile).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not flag a disjoint-when binding on the same key as a conflict', async () => {
|
||||
// VS Code allows multiple bindings for the same key when their `when`
|
||||
// clauses don't overlap. A user's pre-existing cmd+c binding scoped to
|
||||
// editor focus should NOT block our terminal-scoped cmd+c binding.
|
||||
const mkdir = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const readFile = vi.fn().mockResolvedValue(
|
||||
JSON.stringify([
|
||||
{
|
||||
key: 'cmd+c',
|
||||
command: 'editor.action.clipboardCopyAction',
|
||||
when: 'editorFocus'
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const writeFile = vi.fn().mockResolvedValue(undefined)
|
||||
const copyFile = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const result = await configureTerminalKeybindings('vscode', {
|
||||
fileOps: { copyFile, mkdir, readFile, writeFile },
|
||||
homeDir: '/Users/me',
|
||||
platform: 'darwin'
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(writeFile).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('backs up existing keybindings.json only when writing changes', async () => {
|
||||
const mkdir = vi.fn().mockResolvedValue(undefined)
|
||||
const readFile = vi.fn().mockResolvedValue(JSON.stringify([]))
|
||||
@@ -186,6 +329,12 @@ describe('configureTerminalKeybindings', () => {
|
||||
|
||||
const readComplete = vi.fn().mockResolvedValue(
|
||||
JSON.stringify([
|
||||
{
|
||||
key: 'cmd+c',
|
||||
command: 'workbench.action.terminal.sendSequence',
|
||||
when: 'terminalFocus && terminalTextSelected',
|
||||
args: { text: '\u001b[99;13u' }
|
||||
},
|
||||
{
|
||||
key: 'shift+enter',
|
||||
command: 'workbench.action.terminal.sendSequence',
|
||||
|
||||
@@ -44,6 +44,7 @@ describe('input metrics helpers', () => {
|
||||
|
||||
it('reserves gutters on wide panes without starving narrow composer width', () => {
|
||||
expect(stableComposerColumns(100, 3)).toBe(93)
|
||||
expect(stableComposerColumns(100, 5)).toBe(91)
|
||||
expect(stableComposerColumns(10, 3)).toBe(5)
|
||||
expect(stableComposerColumns(6, 3)).toBe(1)
|
||||
})
|
||||
|
||||
@@ -1,46 +1,92 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { DARK_THEME, DEFAULT_THEME, detectLightMode, fromSkin, LIGHT_THEME } from '../theme.js'
|
||||
// `theme.js` reads `process.env` at module-load to compute DEFAULT_THEME,
|
||||
// and `fromSkin` closes over DEFAULT_THEME. A developer shell with
|
||||
// HERMES_TUI_THEME=light (or HERMES_TUI_BACKGROUND set to something
|
||||
// bright) would flip the base and turn these assertions into a local-
|
||||
// only failure. We sterilize the relevant env vars + dynamically
|
||||
// import the module fresh so EVERY symbol that closes over the env
|
||||
// (DEFAULT_THEME, DARK_THEME, LIGHT_THEME, fromSkin) is loaded against
|
||||
// a known-empty environment.
|
||||
//
|
||||
// `detectLightMode` takes env as an explicit arg, so it's safe to import
|
||||
// statically — but we stay consistent and dynamic-import it too.
|
||||
const RELEVANT_ENV = [
|
||||
'HERMES_TUI_LIGHT',
|
||||
'HERMES_TUI_THEME',
|
||||
'HERMES_TUI_BACKGROUND',
|
||||
'COLORFGBG',
|
||||
'TERM_PROGRAM'
|
||||
] as const
|
||||
|
||||
async function importThemeWithCleanEnv() {
|
||||
for (const key of RELEVANT_ENV) {
|
||||
vi.stubEnv(key, '')
|
||||
}
|
||||
|
||||
vi.resetModules()
|
||||
|
||||
return import('../theme.js')
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
describe('DEFAULT_THEME', () => {
|
||||
it('has brand defaults', () => {
|
||||
it('has brand defaults', async () => {
|
||||
const { DEFAULT_THEME } = await importThemeWithCleanEnv()
|
||||
|
||||
expect(DEFAULT_THEME.brand.name).toBe('Hermes Agent')
|
||||
expect(DEFAULT_THEME.brand.prompt).toBe('❯')
|
||||
expect(DEFAULT_THEME.brand.tool).toBe('┊')
|
||||
})
|
||||
|
||||
it('has color palette', () => {
|
||||
expect(DEFAULT_THEME.color.gold).toBe('#FFD700')
|
||||
it('has color palette', async () => {
|
||||
const { DEFAULT_THEME } = await importThemeWithCleanEnv()
|
||||
|
||||
expect(DEFAULT_THEME.color.primary).toBe('#FFD700')
|
||||
expect(DEFAULT_THEME.color.error).toBe('#ef5350')
|
||||
})
|
||||
})
|
||||
|
||||
describe('LIGHT_THEME', () => {
|
||||
it('avoids bright-yellow accents unreadable on white backgrounds (#11300)', () => {
|
||||
expect(LIGHT_THEME.color.gold).not.toBe('#FFD700')
|
||||
expect(LIGHT_THEME.color.amber).not.toBe('#FFBF00')
|
||||
expect(LIGHT_THEME.color.dim).not.toBe('#B8860B')
|
||||
it('avoids bright-yellow accents unreadable on white backgrounds (#11300)', async () => {
|
||||
const { LIGHT_THEME } = await importThemeWithCleanEnv()
|
||||
|
||||
expect(LIGHT_THEME.color.primary).not.toBe('#FFD700')
|
||||
expect(LIGHT_THEME.color.accent).not.toBe('#FFBF00')
|
||||
expect(LIGHT_THEME.color.muted).not.toBe('#B8860B')
|
||||
expect(LIGHT_THEME.color.statusWarn).not.toBe('#FFD700')
|
||||
})
|
||||
|
||||
it('keeps the same shape as DARK_THEME', () => {
|
||||
it('keeps the same shape as DARK_THEME', async () => {
|
||||
const { DARK_THEME, LIGHT_THEME } = await importThemeWithCleanEnv()
|
||||
|
||||
expect(Object.keys(LIGHT_THEME.color).sort()).toEqual(Object.keys(DARK_THEME.color).sort())
|
||||
expect(LIGHT_THEME.brand).toEqual(DARK_THEME.brand)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DEFAULT_THEME aliasing', () => {
|
||||
it('defaults to DARK_THEME when nothing signals light', () => {
|
||||
expect(DEFAULT_THEME).toBe(DARK_THEME)
|
||||
it('defaults to DARK_THEME when nothing signals light', async () => {
|
||||
const { DEFAULT_THEME, DARK_THEME: DARK } = await importThemeWithCleanEnv()
|
||||
|
||||
expect(DEFAULT_THEME).toBe(DARK)
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectLightMode', () => {
|
||||
it('returns false on empty env', () => {
|
||||
it('returns false on empty env', async () => {
|
||||
const { detectLightMode } = await importThemeWithCleanEnv()
|
||||
|
||||
expect(detectLightMode({})).toBe(false)
|
||||
})
|
||||
|
||||
it('honors HERMES_TUI_LIGHT on/off', () => {
|
||||
it('honors HERMES_TUI_LIGHT on/off', async () => {
|
||||
const { detectLightMode } = await importThemeWithCleanEnv()
|
||||
|
||||
expect(detectLightMode({ HERMES_TUI_LIGHT: '1' })).toBe(true)
|
||||
expect(detectLightMode({ HERMES_TUI_LIGHT: 'true' })).toBe(true)
|
||||
expect(detectLightMode({ HERMES_TUI_LIGHT: 'on' })).toBe(true)
|
||||
@@ -48,7 +94,9 @@ describe('detectLightMode', () => {
|
||||
expect(detectLightMode({ HERMES_TUI_LIGHT: 'off' })).toBe(false)
|
||||
})
|
||||
|
||||
it('sniffs COLORFGBG bg slots 7 and 15 as light (#11300)', () => {
|
||||
it('sniffs COLORFGBG bg slots 7 and 15 as light (#11300)', async () => {
|
||||
const { detectLightMode } = await importThemeWithCleanEnv()
|
||||
|
||||
expect(detectLightMode({ COLORFGBG: '0;15' })).toBe(true)
|
||||
expect(detectLightMode({ COLORFGBG: '0;default;15' })).toBe(true)
|
||||
expect(detectLightMode({ COLORFGBG: '0;7' })).toBe(true)
|
||||
@@ -56,38 +104,134 @@ describe('detectLightMode', () => {
|
||||
expect(detectLightMode({ COLORFGBG: '7;default;0' })).toBe(false)
|
||||
})
|
||||
|
||||
it('lets HERMES_TUI_LIGHT=0 override a light COLORFGBG', () => {
|
||||
it('falls through on malformed COLORFGBG with empty/non-numeric trailing field', async () => {
|
||||
const { detectLightMode } = await importThemeWithCleanEnv()
|
||||
// `Number('')` is 0, so `'15;'` would have been read as bg=0
|
||||
// (authoritative dark) and incorrectly blocked TERM_PROGRAM.
|
||||
// The strict /^\d+$/ guard makes these fall through instead.
|
||||
const allowList = new Set(['Apple_Terminal'])
|
||||
|
||||
expect(detectLightMode({ COLORFGBG: '15;', TERM_PROGRAM: 'Apple_Terminal' }, allowList)).toBe(true)
|
||||
expect(detectLightMode({ COLORFGBG: 'default;default', TERM_PROGRAM: 'Apple_Terminal' }, allowList)).toBe(true)
|
||||
// Without an allow-list match, fall-through still defaults to dark.
|
||||
expect(detectLightMode({ COLORFGBG: '15;' })).toBe(false)
|
||||
})
|
||||
|
||||
it('lets HERMES_TUI_LIGHT=0 override a light COLORFGBG', async () => {
|
||||
const { detectLightMode } = await importThemeWithCleanEnv()
|
||||
|
||||
expect(detectLightMode({ COLORFGBG: '0;15', HERMES_TUI_LIGHT: '0' })).toBe(false)
|
||||
})
|
||||
|
||||
it('honors HERMES_TUI_THEME=light/dark as a symmetric explicit override', async () => {
|
||||
const { detectLightMode } = await importThemeWithCleanEnv()
|
||||
|
||||
expect(detectLightMode({ HERMES_TUI_THEME: 'light' })).toBe(true)
|
||||
expect(detectLightMode({ HERMES_TUI_THEME: 'dark' })).toBe(false)
|
||||
expect(detectLightMode({ COLORFGBG: '0;15', HERMES_TUI_THEME: 'dark' })).toBe(false)
|
||||
expect(detectLightMode({ COLORFGBG: '15;0', HERMES_TUI_THEME: 'light' })).toBe(true)
|
||||
})
|
||||
|
||||
it('uses HERMES_TUI_BACKGROUND luminance when COLORFGBG is missing', async () => {
|
||||
const { detectLightMode } = await importThemeWithCleanEnv()
|
||||
|
||||
expect(detectLightMode({ HERMES_TUI_BACKGROUND: '#ffffff' })).toBe(true)
|
||||
expect(detectLightMode({ HERMES_TUI_BACKGROUND: '#000000' })).toBe(false)
|
||||
expect(detectLightMode({ HERMES_TUI_BACKGROUND: '#1e1e1e' })).toBe(false)
|
||||
// Three-char hex normalises like CSS.
|
||||
expect(detectLightMode({ HERMES_TUI_BACKGROUND: '#fff' })).toBe(true)
|
||||
// Garbage falls through to the default-dark path.
|
||||
expect(detectLightMode({ HERMES_TUI_BACKGROUND: 'not-a-colour' })).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects partially-invalid hex instead of silently truncating', async () => {
|
||||
const { detectLightMode } = await importThemeWithCleanEnv()
|
||||
// `parseInt('fffgff'.slice(2,4), 16)` would return 15 — the strict
|
||||
// regex must reject these inputs so they fall through to default-
|
||||
// dark instead of producing a false-positive light reading.
|
||||
expect(detectLightMode({ HERMES_TUI_BACKGROUND: '#fffgff' })).toBe(false)
|
||||
expect(detectLightMode({ HERMES_TUI_BACKGROUND: 'ffggff' })).toBe(false)
|
||||
expect(detectLightMode({ HERMES_TUI_BACKGROUND: '#xyz' })).toBe(false)
|
||||
// Wrong length also rejected (no implicit padding/truncation).
|
||||
expect(detectLightMode({ HERMES_TUI_BACKGROUND: '#fffff' })).toBe(false)
|
||||
expect(detectLightMode({ HERMES_TUI_BACKGROUND: '#fffffff' })).toBe(false)
|
||||
})
|
||||
|
||||
it('treats COLORFGBG as authoritative when present so it dominates the TERM_PROGRAM allow-list', async () => {
|
||||
const { detectLightMode } = await importThemeWithCleanEnv()
|
||||
// Inject a light-default allow-list so the precedence test is
|
||||
// meaningful even though the production allow-list is empty.
|
||||
const allowList = new Set(['Apple_Terminal'])
|
||||
|
||||
// Sanity: the allow-list alone WOULD turn this terminal light.
|
||||
expect(detectLightMode({ TERM_PROGRAM: 'Apple_Terminal' }, allowList)).toBe(true)
|
||||
|
||||
// Dark COLORFGBG must beat the allow-list.
|
||||
expect(detectLightMode({ COLORFGBG: '15;0', TERM_PROGRAM: 'Apple_Terminal' }, allowList)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fromSkin', () => {
|
||||
it('overrides banner colors', () => {
|
||||
expect(fromSkin({ banner_title: '#FF0000' }, {}).color.gold).toBe('#FF0000')
|
||||
// `fromSkin` closes over DEFAULT_THEME (which is env-derived), so we
|
||||
// must dynamic-import it after sterilizing env — otherwise an ambient
|
||||
// HERMES_TUI_THEME=light would flip the base palette and make these
|
||||
// assertions order-dependent on the developer's shell.
|
||||
|
||||
it('overrides banner colors', async () => {
|
||||
const { fromSkin } = await importThemeWithCleanEnv()
|
||||
|
||||
expect(fromSkin({ banner_title: '#FF0000' }, {}).color.primary).toBe('#FF0000')
|
||||
})
|
||||
|
||||
it('preserves unset colors', () => {
|
||||
expect(fromSkin({ banner_title: '#FF0000' }, {}).color.amber).toBe(DEFAULT_THEME.color.amber)
|
||||
it('preserves unset colors', async () => {
|
||||
const { DEFAULT_THEME, fromSkin } = await importThemeWithCleanEnv()
|
||||
|
||||
expect(fromSkin({ banner_title: '#FF0000' }, {}).color.accent).toBe(DEFAULT_THEME.color.accent)
|
||||
})
|
||||
|
||||
it('overrides branding', () => {
|
||||
it('derives completion current background from resolved completion background', async () => {
|
||||
const { fromSkin } = await importThemeWithCleanEnv()
|
||||
|
||||
const theme = fromSkin({ banner_accent: '#000000', completion_menu_bg: '#ffffff' }, {})
|
||||
|
||||
expect(theme.color.completionBg).toBe('#ffffff')
|
||||
expect(theme.color.completionCurrentBg).toBe('#bfbfbf')
|
||||
})
|
||||
|
||||
it('overrides branding', async () => {
|
||||
const { fromSkin } = await importThemeWithCleanEnv()
|
||||
const { brand } = fromSkin({}, { agent_name: 'TestBot', prompt_symbol: '$' })
|
||||
|
||||
expect(brand.name).toBe('TestBot')
|
||||
expect(brand.prompt).toBe('$')
|
||||
})
|
||||
|
||||
it('defaults for empty skin', () => {
|
||||
it('normalizes skin prompt symbols to trimmed single-line text', async () => {
|
||||
const { DEFAULT_THEME, fromSkin } = await importThemeWithCleanEnv()
|
||||
|
||||
expect(fromSkin({}, { prompt_symbol: ' ⚔ ❯ \n' }).brand.prompt).toBe('⚔ ❯')
|
||||
expect(fromSkin({}, { prompt_symbol: ' Ψ > \n' }).brand.prompt).toBe('Ψ >')
|
||||
expect(fromSkin({}, { prompt_symbol: '\n\t' }).brand.prompt).toBe(DEFAULT_THEME.brand.prompt)
|
||||
})
|
||||
|
||||
it('defaults for empty skin', async () => {
|
||||
const { DEFAULT_THEME, fromSkin } = await importThemeWithCleanEnv()
|
||||
|
||||
expect(fromSkin({}, {}).color).toEqual(DEFAULT_THEME.color)
|
||||
expect(fromSkin({}, {}).brand.icon).toBe(DEFAULT_THEME.brand.icon)
|
||||
})
|
||||
|
||||
it('passes banner logo/hero', () => {
|
||||
it('passes banner logo/hero', async () => {
|
||||
const { fromSkin } = await importThemeWithCleanEnv()
|
||||
|
||||
expect(fromSkin({}, {}, 'LOGO', 'HERO').bannerLogo).toBe('LOGO')
|
||||
expect(fromSkin({}, {}, 'LOGO', 'HERO').bannerHero).toBe('HERO')
|
||||
})
|
||||
|
||||
it('maps ui_ color keys + cascades to status', () => {
|
||||
it('maps ui_ color keys + cascades to status', async () => {
|
||||
const { fromSkin } = await importThemeWithCleanEnv()
|
||||
const { color } = fromSkin({ ui_ok: '#008000' }, {})
|
||||
|
||||
expect(color.ok).toBe('#008000')
|
||||
expect(color.statusGood).toBe('#008000')
|
||||
})
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $uiState, resetUiState } from '../app/uiStore.js'
|
||||
import { applyDisplay, normalizeBusyInputMode, normalizeStatusBar } from '../app/useConfigSync.js'
|
||||
import {
|
||||
applyDisplay,
|
||||
normalizeBusyInputMode,
|
||||
normalizeIndicatorStyle,
|
||||
normalizeMouseTracking,
|
||||
normalizeStatusBar
|
||||
} from '../app/useConfigSync.js'
|
||||
|
||||
describe('applyDisplay', () => {
|
||||
beforeEach(() => {
|
||||
@@ -65,6 +71,19 @@ describe('applyDisplay', () => {
|
||||
expect(s.sections).toEqual({})
|
||||
})
|
||||
|
||||
it('uses documented mouse_tracking with legacy tui_mouse fallback', () => {
|
||||
const setBell = vi.fn()
|
||||
|
||||
applyDisplay({ config: { display: { mouse_tracking: false } } }, setBell)
|
||||
expect($uiState.get().mouseTracking).toBe(false)
|
||||
|
||||
applyDisplay({ config: { display: { mouse_tracking: true, tui_mouse: false } } }, setBell)
|
||||
expect($uiState.get().mouseTracking).toBe(true)
|
||||
|
||||
applyDisplay({ config: { display: { tui_mouse: false } } }, setBell)
|
||||
expect($uiState.get().mouseTracking).toBe(false)
|
||||
})
|
||||
|
||||
it('parses display.sections into per-section overrides', () => {
|
||||
const setBell = vi.fn()
|
||||
|
||||
@@ -161,6 +180,19 @@ describe('normalizeStatusBar', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeMouseTracking', () => {
|
||||
it('defaults on and prefers canonical mouse_tracking over legacy tui_mouse', () => {
|
||||
expect(normalizeMouseTracking({})).toBe(true)
|
||||
expect(normalizeMouseTracking({ mouse_tracking: false })).toBe(false)
|
||||
expect(normalizeMouseTracking({ mouse_tracking: 0 })).toBe(false)
|
||||
expect(normalizeMouseTracking({ mouse_tracking: 'off' })).toBe(false)
|
||||
expect(normalizeMouseTracking({ mouse_tracking: 'false' })).toBe(false)
|
||||
expect(normalizeMouseTracking({ mouse_tracking: null, tui_mouse: false })).toBe(true)
|
||||
expect(normalizeMouseTracking({ mouse_tracking: true, tui_mouse: false })).toBe(true)
|
||||
expect(normalizeMouseTracking({ tui_mouse: false })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeBusyInputMode', () => {
|
||||
it('passes through the canonical CLI parity values', () => {
|
||||
expect(normalizeBusyInputMode('queue')).toBe('queue')
|
||||
@@ -187,6 +219,28 @@ describe('normalizeBusyInputMode', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeIndicatorStyle', () => {
|
||||
it('passes through the canonical enum', () => {
|
||||
expect(normalizeIndicatorStyle('kaomoji')).toBe('kaomoji')
|
||||
expect(normalizeIndicatorStyle('emoji')).toBe('emoji')
|
||||
expect(normalizeIndicatorStyle('unicode')).toBe('unicode')
|
||||
expect(normalizeIndicatorStyle('ascii')).toBe('ascii')
|
||||
})
|
||||
|
||||
it('trims and lowercases input', () => {
|
||||
expect(normalizeIndicatorStyle(' Emoji ')).toBe('emoji')
|
||||
expect(normalizeIndicatorStyle('UNICODE')).toBe('unicode')
|
||||
})
|
||||
|
||||
it('defaults to kaomoji for missing/unknown values', () => {
|
||||
expect(normalizeIndicatorStyle(undefined)).toBe('kaomoji')
|
||||
expect(normalizeIndicatorStyle(null)).toBe('kaomoji')
|
||||
expect(normalizeIndicatorStyle('')).toBe('kaomoji')
|
||||
expect(normalizeIndicatorStyle('sparkle')).toBe('kaomoji')
|
||||
expect(normalizeIndicatorStyle(42)).toBe('kaomoji')
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyDisplay → busy_input_mode', () => {
|
||||
beforeEach(() => {
|
||||
resetUiState()
|
||||
@@ -212,3 +266,29 @@ describe('applyDisplay → busy_input_mode', () => {
|
||||
expect($uiState.get().busyInputMode).toBe('queue')
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyDisplay → tui_status_indicator', () => {
|
||||
beforeEach(() => {
|
||||
resetUiState()
|
||||
})
|
||||
|
||||
it('threads display.tui_status_indicator into $uiState', () => {
|
||||
const setBell = vi.fn()
|
||||
|
||||
applyDisplay({ config: { display: { tui_status_indicator: 'emoji' } } }, setBell)
|
||||
expect($uiState.get().indicatorStyle).toBe('emoji')
|
||||
|
||||
applyDisplay({ config: { display: { tui_status_indicator: 'unicode' } } }, setBell)
|
||||
expect($uiState.get().indicatorStyle).toBe('unicode')
|
||||
})
|
||||
|
||||
it('falls back to kaomoji default when missing or invalid', () => {
|
||||
const setBell = vi.fn()
|
||||
|
||||
applyDisplay({ config: { display: {} } }, setBell)
|
||||
expect($uiState.get().indicatorStyle).toBe('kaomoji')
|
||||
|
||||
applyDisplay({ config: { display: { tui_status_indicator: 'rainbow' } } }, setBell)
|
||||
expect($uiState.get().indicatorStyle).toBe('kaomoji')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -28,4 +28,31 @@ describe('stickyPromptFromViewport', () => {
|
||||
|
||||
expect(stickyPromptFromViewport(messages, offsets, 16, 20, false)).toBe('current prompt')
|
||||
})
|
||||
|
||||
it('shows the last prompt once the viewport starts after the history tail', () => {
|
||||
const messages = [
|
||||
{ role: 'user' as const, text: 'current prompt' },
|
||||
{ role: 'assistant' as const, text: 'completed answer' }
|
||||
]
|
||||
|
||||
expect(stickyPromptFromViewport(messages, [0, 2, 5], 8, 14, false)).toBe('current prompt')
|
||||
})
|
||||
|
||||
it('shows a prompt as soon as its full row is above the viewport', () => {
|
||||
const messages = [
|
||||
{ role: 'user' as const, text: 'current prompt' },
|
||||
{ role: 'assistant' as const, text: 'current answer' }
|
||||
]
|
||||
|
||||
expect(stickyPromptFromViewport(messages, [0, 2, 10], 2, 8, false)).toBe('current prompt')
|
||||
})
|
||||
|
||||
it('hides the sticky prompt at the bottom', () => {
|
||||
const messages = [
|
||||
{ role: 'user' as const, text: 'current prompt' },
|
||||
{ role: 'assistant' as const, text: 'current answer' }
|
||||
]
|
||||
|
||||
expect(stickyPromptFromViewport(messages, [0, 2, 10], 8, 10, true)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,4 +35,20 @@ describe('viewportStore', () => {
|
||||
})
|
||||
expect(viewportSnapshotKey(snap)).toBe('0:16:5:40:3')
|
||||
})
|
||||
|
||||
it('uses fresh scroll height to clear stale non-bottom state', () => {
|
||||
const handle = {
|
||||
getFreshScrollHeight: () => 20,
|
||||
getPendingDelta: () => 0,
|
||||
getScrollHeight: () => 40,
|
||||
getScrollTop: () => 15,
|
||||
getViewportHeight: () => 5,
|
||||
isSticky: () => false
|
||||
}
|
||||
|
||||
const snap = getViewportSnapshot(handle as any)
|
||||
|
||||
expect(snap.atBottom).toBe(true)
|
||||
expect(snap.scrollHeight).toBe(20)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -373,6 +373,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
||||
// 120-char clip used for `gateway.stderr` activity entries.
|
||||
const STDERR_LINE_CAP = 120
|
||||
const STDERR_LINES_MAX = 8
|
||||
|
||||
const tailLines = (stderrTail ?? '')
|
||||
.split('\n')
|
||||
.map(l => l.trim())
|
||||
|
||||
@@ -29,11 +29,21 @@ export type StatusBarMode = 'bottom' | 'off' | 'top'
|
||||
|
||||
export type BusyInputMode = 'interrupt' | 'queue' | 'steer'
|
||||
|
||||
// Single source of truth for indicator style names. Union type is
|
||||
// derived from this tuple so adding/removing a style only touches one
|
||||
// line — `useConfigSync` (validation) and `session.ts` (slash arg
|
||||
// validation + usage hint) both import it.
|
||||
export const INDICATOR_STYLES = ['ascii', 'emoji', 'kaomoji', 'unicode'] as const
|
||||
export type IndicatorStyle = (typeof INDICATOR_STYLES)[number]
|
||||
export const DEFAULT_INDICATOR_STYLE: IndicatorStyle = 'kaomoji'
|
||||
|
||||
export interface SelectionApi {
|
||||
captureScrolledRows: (firstRow: number, lastRow: number, side: 'above' | 'below') => void
|
||||
clearSelection: () => void
|
||||
copySelection: () => Promise<string>
|
||||
copySelectionNoClear: () => Promise<string>
|
||||
getState: () => unknown
|
||||
version: () => number
|
||||
shiftAnchor: (dRow: number, minRow: number, maxRow: number) => void
|
||||
shiftSelection: (dRow: number, minRow: number, maxRow: number) => void
|
||||
}
|
||||
@@ -97,6 +107,7 @@ export interface UiState {
|
||||
sections: SectionVisibility
|
||||
showCost: boolean
|
||||
showReasoning: boolean
|
||||
indicatorStyle: IndicatorStyle
|
||||
sid: null | string
|
||||
status: string
|
||||
statusBar: StatusBarMode
|
||||
|
||||
@@ -503,7 +503,7 @@ export const coreCommands: SlashCommand[] = [
|
||||
ctx.guarded<SessionSteerResponse>(r => {
|
||||
if (r?.status === 'queued') {
|
||||
ctx.transcript.sys(
|
||||
`⏩ steer queued — arrives after next tool call: "${payload.slice(0, 50)}${payload.length > 50 ? '…' : ''}"`
|
||||
`steer queued — arrives after next tool call: "${payload.slice(0, 50)}${payload.length > 50 ? '…' : ''}"`
|
||||
)
|
||||
} else {
|
||||
ctx.transcript.sys('steer rejected')
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
} from '../../../gatewayTypes.js'
|
||||
import { fmtK } from '../../../lib/text.js'
|
||||
import type { PanelSection } from '../../../types.js'
|
||||
import { DEFAULT_INDICATOR_STYLE, INDICATOR_STYLES, type IndicatorStyle } from '../../interfaces.js'
|
||||
import { patchOverlayState } from '../../overlayStore.js'
|
||||
import { patchUiState } from '../../uiStore.js'
|
||||
import type { SlashCommand } from '../types.js'
|
||||
@@ -268,6 +269,43 @@ export const sessionCommands: SlashCommand[] = [
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
help: 'pick the busy indicator: kaomoji (default), emoji, unicode (braille), or ascii',
|
||||
name: 'indicator',
|
||||
usage: `/indicator [${INDICATOR_STYLES.join('|')}]`,
|
||||
run: (arg, ctx) => {
|
||||
const value = arg.trim().toLowerCase()
|
||||
|
||||
if (!value) {
|
||||
return ctx.gateway
|
||||
.rpc<ConfigGetValueResponse>('config.get', { key: 'indicator' })
|
||||
.then(
|
||||
ctx.guarded<ConfigGetValueResponse>(r =>
|
||||
ctx.transcript.sys(`indicator: ${r.value || DEFAULT_INDICATOR_STYLE}`)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (!(INDICATOR_STYLES as readonly string[]).includes(value)) {
|
||||
return ctx.transcript.sys(`usage: /indicator [${INDICATOR_STYLES.join('|')}]`)
|
||||
}
|
||||
|
||||
ctx.gateway.rpc<ConfigSetResponse>('config.set', { key: 'indicator', value }).then(
|
||||
ctx.guarded<ConfigSetResponse>(r => {
|
||||
if (!r.value) {
|
||||
return
|
||||
}
|
||||
|
||||
// Hot-swap the running TUI immediately so the next render
|
||||
// uses the new style without waiting for the 5s mtime poll
|
||||
// to re-apply config.full.
|
||||
patchUiState({ indicatorStyle: value as IndicatorStyle })
|
||||
ctx.transcript.sys(`indicator → ${r.value}`)
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
help: 'toggle yolo mode (per-session approvals)',
|
||||
name: 'yolo',
|
||||
|
||||
@@ -4,7 +4,7 @@ import { MOUSE_TRACKING } from '../config/env.js'
|
||||
import { ZERO } from '../domain/usage.js'
|
||||
import { DEFAULT_THEME } from '../theme.js'
|
||||
|
||||
import type { UiState } from './interfaces.js'
|
||||
import { DEFAULT_INDICATOR_STYLE, type UiState } from './interfaces.js'
|
||||
|
||||
const buildUiState = (): UiState => ({
|
||||
bgTasks: new Set(),
|
||||
@@ -13,6 +13,7 @@ const buildUiState = (): UiState => ({
|
||||
compact: false,
|
||||
detailsMode: 'collapsed',
|
||||
detailsModeCommandOverride: false,
|
||||
indicatorStyle: DEFAULT_INDICATOR_STYLE,
|
||||
info: null,
|
||||
inlineDiffs: true,
|
||||
mouseTracking: MOUSE_TRACKING,
|
||||
|
||||
@@ -10,7 +10,13 @@ import type {
|
||||
} from '../gatewayTypes.js'
|
||||
import { asRpcResult } from '../lib/rpc.js'
|
||||
|
||||
import type { BusyInputMode, StatusBarMode } from './interfaces.js'
|
||||
import {
|
||||
type BusyInputMode,
|
||||
DEFAULT_INDICATOR_STYLE,
|
||||
INDICATOR_STYLES,
|
||||
type IndicatorStyle,
|
||||
type StatusBarMode
|
||||
} from './interfaces.js'
|
||||
import { turnController } from './turnController.js'
|
||||
import { patchUiState } from './uiStore.js'
|
||||
|
||||
@@ -45,6 +51,31 @@ export const normalizeBusyInputMode = (raw: unknown): BusyInputMode => {
|
||||
return BUSY_MODES.has(v) ? v : TUI_BUSY_DEFAULT
|
||||
}
|
||||
|
||||
const INDICATOR_STYLE_SET: ReadonlySet<IndicatorStyle> = new Set(INDICATOR_STYLES)
|
||||
|
||||
export const normalizeIndicatorStyle = (raw: unknown): IndicatorStyle => {
|
||||
if (typeof raw !== 'string') {
|
||||
return DEFAULT_INDICATOR_STYLE
|
||||
}
|
||||
|
||||
const v = raw.trim().toLowerCase() as IndicatorStyle
|
||||
|
||||
return INDICATOR_STYLE_SET.has(v) ? v : DEFAULT_INDICATOR_STYLE
|
||||
}
|
||||
|
||||
const FALSEY_MOUSE = new Set(['0', 'false', 'no', 'off'])
|
||||
const hasOwn = (obj: object, key: PropertyKey) => Object.prototype.hasOwnProperty.call(obj, key)
|
||||
|
||||
export const normalizeMouseTracking = (display: { mouse_tracking?: unknown; tui_mouse?: unknown }): boolean => {
|
||||
const raw = hasOwn(display, 'mouse_tracking') ? display.mouse_tracking : display.tui_mouse
|
||||
|
||||
if (raw === false || raw === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
return typeof raw === 'string' ? !FALSEY_MOUSE.has(raw.trim().toLowerCase()) : true
|
||||
}
|
||||
|
||||
const MTIME_POLL_MS = 5000
|
||||
|
||||
const quietRpc = async <T extends Record<string, any> = Record<string, any>>(
|
||||
@@ -68,8 +99,9 @@ export const applyDisplay = (cfg: ConfigFullResponse | null, setBell: (v: boolea
|
||||
compact: !!d.tui_compact,
|
||||
detailsMode: resolveDetailsMode(d),
|
||||
detailsModeCommandOverride: false,
|
||||
indicatorStyle: normalizeIndicatorStyle(d.tui_status_indicator),
|
||||
inlineDiffs: d.inline_diffs !== false,
|
||||
mouseTracking: d.tui_mouse !== false,
|
||||
mouseTracking: normalizeMouseTracking(d),
|
||||
sections: resolveSections(d.sections),
|
||||
showCost: !!d.show_cost,
|
||||
showReasoning: !!d.show_reasoning,
|
||||
|
||||
@@ -366,6 +366,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
||||
|
||||
if (isCtrl(key, ch, 'x') && cState.queueEditIdx !== null) {
|
||||
cActions.removeQueue(cState.queueEditIdx)
|
||||
|
||||
return cActions.clearIn()
|
||||
}
|
||||
|
||||
@@ -393,6 +394,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
||||
if (isAction(key, ch, 'l')) {
|
||||
clearSelection()
|
||||
forceRedraw(terminal.stdout ?? process.stdout)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
import { useGitBranch } from '../hooks/useGitBranch.js'
|
||||
import { useVirtualHistory } from '../hooks/useVirtualHistory.js'
|
||||
import { appendTranscriptMessage } from '../lib/messages.js'
|
||||
import { isMac } from '../lib/platform.js'
|
||||
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
|
||||
import { terminalParityHints } from '../lib/terminalParity.js'
|
||||
import { buildToolTrailLine, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js'
|
||||
@@ -52,7 +53,7 @@ const capHistory = (items: Msg[]): Msg[] => {
|
||||
return items[0]?.kind === 'intro' ? [items[0]!, ...items.slice(-(MAX_HISTORY - 1))] : items.slice(-MAX_HISTORY)
|
||||
}
|
||||
|
||||
const statusColorOf = (status: string, t: { dim: string; error: string; ok: string; warn: string }) => {
|
||||
const statusColorOf = (status: string, t: { error: string; muted: string; ok: string; warn: string }) => {
|
||||
if (status === 'ready') {
|
||||
return t.ok
|
||||
}
|
||||
@@ -65,7 +66,7 @@ const statusColorOf = (status: string, t: { dim: string; error: string; ok: stri
|
||||
return t.warn
|
||||
}
|
||||
|
||||
return t.dim
|
||||
return t.muted
|
||||
}
|
||||
|
||||
export function useMainApp(gw: GatewayClient) {
|
||||
@@ -143,11 +144,47 @@ export function useMainApp(gw: GatewayClient) {
|
||||
|
||||
const hasSelection = useHasSelection()
|
||||
const selection = useSelection()
|
||||
const lastCopiedVersionRef = useRef(-1)
|
||||
|
||||
useEffect(() => {
|
||||
selection.setSelectionBgColor(ui.theme.color.selectionBg)
|
||||
}, [selection, ui.theme.color.selectionBg])
|
||||
|
||||
// macOS Terminal.app does not forward Cmd+C to fullscreen TUIs that enable
|
||||
// mouse tracking, so the only reliable native-feeling path is iTerm-style
|
||||
// copy-on-select: once a drag creates a stable TUI selection, write it to
|
||||
// the system clipboard while keeping the highlight visible.
|
||||
//
|
||||
// Subscribe directly via the ink selection bus (not useSyncExternalStore)
|
||||
// so React doesn't re-render MainApp on every drag-move tick. The version
|
||||
// ref de-dupes against re-entrant notifications.
|
||||
useEffect(() => {
|
||||
if (!isMac) {
|
||||
return
|
||||
}
|
||||
|
||||
return selection.subscribe(() => {
|
||||
if (!selection.hasSelection()) {
|
||||
return
|
||||
}
|
||||
|
||||
const state = selection.getState() as { isDragging?: boolean } | null
|
||||
|
||||
if (state?.isDragging) {
|
||||
return
|
||||
}
|
||||
|
||||
const version = selection.version()
|
||||
|
||||
if (version === lastCopiedVersionRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
lastCopiedVersionRef.current = version
|
||||
void selection.copySelectionNoClear()
|
||||
})
|
||||
}, [selection])
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
selection.clearSelection()
|
||||
getInputSelection()?.collapseToEnd()
|
||||
|
||||
@@ -227,6 +227,7 @@ export function useSubmission(opts: UseSubmissionOptions) {
|
||||
(full: string, opts: { fallbackToFront?: boolean } = {}) => {
|
||||
const live = getUiState()
|
||||
const mode = live.busyInputMode
|
||||
|
||||
const fallback = (note: string) => {
|
||||
if (opts.fallbackToFront) {
|
||||
composerRefs.queueRef.current.unshift(full)
|
||||
@@ -234,6 +235,7 @@ export function useSubmission(opts: UseSubmissionOptions) {
|
||||
} else {
|
||||
composerActions.enqueue(full)
|
||||
}
|
||||
|
||||
sys(note)
|
||||
}
|
||||
|
||||
@@ -350,17 +352,7 @@ export function useSubmission(opts: UseSubmissionOptions) {
|
||||
|
||||
send(full)
|
||||
},
|
||||
[
|
||||
appendMessage,
|
||||
composerActions,
|
||||
composerRefs,
|
||||
handleBusyInput,
|
||||
interpolate,
|
||||
send,
|
||||
sendQueued,
|
||||
shellExec,
|
||||
slashRef
|
||||
]
|
||||
[appendMessage, composerActions, composerRefs, handleBusyInput, interpolate, send, sendQueued, shellExec, slashRef]
|
||||
)
|
||||
|
||||
const submit = useCallback(
|
||||
|
||||
@@ -74,9 +74,9 @@ const LOGO_GRADIENT = [0, 0, 1, 1, 2, 2] as const
|
||||
const CADUC_GRADIENT = [2, 2, 1, 1, 0, 0, 1, 1, 2, 2, 3, 3, 3, 3, 3] as const
|
||||
|
||||
const colorize = (art: string[], gradient: readonly number[], c: ThemeColors): Line[] => {
|
||||
const p = [c.gold, c.amber, c.bronze, c.dim]
|
||||
const p = [c.primary, c.accent, c.border, c.muted]
|
||||
|
||||
return art.map((text, i) => [p[gradient[i]!] ?? c.dim, text])
|
||||
return art.map((text, i) => [p[gradient[i]!] ?? c.muted, text])
|
||||
}
|
||||
|
||||
export const LOGO_WIDTH = 98
|
||||
|
||||
@@ -79,15 +79,15 @@ const FILTER_PREDICATES: Record<FilterMode, (n: SubagentNode) => boolean> = {
|
||||
}
|
||||
|
||||
const STATUS_GLYPH: Record<Status, { color: (t: Theme) => string; glyph: string }> = {
|
||||
running: { color: t => t.color.amber, glyph: '●' },
|
||||
queued: { color: t => t.color.dim, glyph: '○' },
|
||||
running: { color: t => t.color.accent, glyph: '●' },
|
||||
queued: { color: t => t.color.muted, glyph: '○' },
|
||||
completed: { color: t => t.color.statusGood, glyph: '✓' },
|
||||
interrupted: { color: t => t.color.warn, glyph: '■' },
|
||||
failed: { color: t => t.color.error, glyph: '✗' }
|
||||
}
|
||||
|
||||
// Heatmap palette — cold → hot, resolved against the active theme.
|
||||
const heatPalette = (t: Theme) => [t.color.bronze, t.color.amber, t.color.gold, t.color.warn, t.color.error]
|
||||
const heatPalette = (t: Theme) => [t.color.border, t.color.accent, t.color.primary, t.color.warn, t.color.error]
|
||||
|
||||
// ── Pure helpers ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -160,8 +160,8 @@ 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.gold : t.color.amber
|
||||
const trackColor = hover ? t.color.bronze : t.color.dim
|
||||
const thumbColor = grab !== null ? t.color.primary : t.color.accent
|
||||
const trackColor = hover ? t.color.border : t.color.muted
|
||||
|
||||
const jump = (row: number, offset: number) => {
|
||||
if (!s || !scrollable) {
|
||||
@@ -301,7 +301,7 @@ function GanttStrip({
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color={t.color.dim}>
|
||||
<Text color={t.color.muted}>
|
||||
Timeline · {fmtElapsedLabel(Math.max(0, totalSeconds))}
|
||||
{windowLabel}
|
||||
</Text>
|
||||
@@ -309,7 +309,7 @@ function GanttStrip({
|
||||
{shown.map(({ endAt, idx, node, startAt }) => {
|
||||
const active = idx === cursor
|
||||
const { color } = statusGlyph(node.item, t)
|
||||
const accent = active ? t.color.amber : t.color.dim
|
||||
const accent = active ? t.color.accent : t.color.muted
|
||||
|
||||
const elSec = displayElapsedSeconds(node.item, now)
|
||||
const elLabel = elSec != null ? fmtElapsedLabel(elSec) : ''
|
||||
@@ -321,7 +321,7 @@ function GanttStrip({
|
||||
{' '}
|
||||
</Text>
|
||||
|
||||
<Text color={active ? t.color.amber : color}>{bar(startAt, endAt)}</Text>
|
||||
<Text color={active ? t.color.accent : color}>{bar(startAt, endAt)}</Text>
|
||||
|
||||
{elLabel ? (
|
||||
<Text color={accent}>
|
||||
@@ -333,13 +333,13 @@ function GanttStrip({
|
||||
)
|
||||
})}
|
||||
|
||||
<Text color={t.color.dim} dim>
|
||||
<Text color={t.color.muted} dim>
|
||||
{' '}
|
||||
{ruler}
|
||||
</Text>
|
||||
|
||||
{totalSeconds > 0 ? (
|
||||
<Text color={t.color.dim} dim>
|
||||
<Text color={t.color.muted} dim>
|
||||
{' '}
|
||||
{rulerLabels}
|
||||
</Text>
|
||||
@@ -368,7 +368,7 @@ function OverlaySection({
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box onClick={() => toggleOverlaySection(title, defaultOpen)}>
|
||||
<Text color={t.color.label}>
|
||||
<Text color={t.color.amber}>{open ? '▾ ' : '▸ '}</Text>
|
||||
<Text color={t.color.accent}>{open ? '▾ ' : '▸ '}</Text>
|
||||
{title}
|
||||
{typeof count === 'number' ? ` (${count})` : ''}
|
||||
</Text>
|
||||
@@ -383,7 +383,7 @@ function Field({ name, t, value }: { name: string; t: Theme; value: ReactNode })
|
||||
return (
|
||||
<Text wrap="truncate-end">
|
||||
<Text color={t.color.label}>{name} · </Text>
|
||||
<Text color={t.color.cornsilk}>{value}</Text>
|
||||
<Text color={t.color.text}>{value}</Text>
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
@@ -411,8 +411,8 @@ function Detail({ id, node, t }: { id?: string; node: SubagentNode; t: Theme })
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={t.color.cornsilk} wrap="wrap">
|
||||
{id ? <Text color={t.color.amber}>#{id} </Text> : null}
|
||||
<Text bold color={t.color.text} wrap="wrap">
|
||||
{id ? <Text color={t.color.accent}>#{id} </Text> : null}
|
||||
<Text color={color}>{glyph}</Text> {item.goal}
|
||||
</Text>
|
||||
|
||||
@@ -472,20 +472,20 @@ function Detail({ id, node, t }: { id?: string; node: SubagentNode; t: Theme })
|
||||
))}
|
||||
|
||||
{filesRead.slice(0, 8).map((p, i) => (
|
||||
<Text color={t.color.cornsilk} key={`r-${i}`} wrap="truncate-end">
|
||||
<Text color={t.color.dim}>·</Text> {p}
|
||||
<Text color={t.color.text} key={`r-${i}`} wrap="truncate-end">
|
||||
<Text color={t.color.muted}>·</Text> {p}
|
||||
</Text>
|
||||
))}
|
||||
|
||||
{filesOverflow > 0 ? <Text color={t.color.dim}>…+{filesOverflow} more</Text> : null}
|
||||
{filesOverflow > 0 ? <Text color={t.color.muted}>…+{filesOverflow} more</Text> : null}
|
||||
</OverlaySection>
|
||||
) : null}
|
||||
|
||||
{toolLines.length > 0 ? (
|
||||
<OverlaySection count={toolLines.length} defaultOpen t={t} title="Tool calls">
|
||||
{toolLines.map((line, i) => (
|
||||
<Text color={t.color.cornsilk} key={i} wrap="wrap">
|
||||
<Text color={t.color.dim}>·</Text> {line}
|
||||
<Text color={t.color.text} key={i} wrap="wrap">
|
||||
<Text color={t.color.muted}>·</Text> {line}
|
||||
</Text>
|
||||
))}
|
||||
</OverlaySection>
|
||||
@@ -494,8 +494,8 @@ function Detail({ id, node, t }: { id?: string; node: SubagentNode; t: Theme })
|
||||
{outputTail.length > 0 ? (
|
||||
<OverlaySection count={outputTail.length} defaultOpen t={t} title="Output">
|
||||
{outputTail.map((entry, i) => (
|
||||
<Text color={entry.isError ? t.color.error : t.color.cornsilk} key={i} wrap="wrap">
|
||||
<Text bold color={entry.isError ? t.color.error : t.color.amber}>
|
||||
<Text color={entry.isError ? t.color.error : t.color.text} key={i} wrap="wrap">
|
||||
<Text bold color={entry.isError ? t.color.error : t.color.accent}>
|
||||
{entry.tool}
|
||||
</Text>{' '}
|
||||
{entry.preview}
|
||||
@@ -507,7 +507,7 @@ function Detail({ id, node, t }: { id?: string; node: SubagentNode; t: Theme })
|
||||
{item.notes.length ? (
|
||||
<OverlaySection count={item.notes.length} t={t} title="Progress">
|
||||
{item.notes.slice(-6).map((line, i) => (
|
||||
<Text color={t.color.cornsilk} key={i} wrap="wrap">
|
||||
<Text color={t.color.text} key={i} wrap="wrap">
|
||||
<Text color={t.color.label}>·</Text> {line}
|
||||
</Text>
|
||||
))}
|
||||
@@ -516,7 +516,7 @@ function Detail({ id, node, t }: { id?: string; node: SubagentNode; t: Theme })
|
||||
|
||||
{item.summary ? (
|
||||
<OverlaySection defaultOpen t={t} title="Summary">
|
||||
<Text color={t.color.cornsilk} wrap="wrap">
|
||||
<Text color={t.color.text} wrap="wrap">
|
||||
{item.summary}
|
||||
</Text>
|
||||
</OverlaySection>
|
||||
@@ -552,16 +552,16 @@ 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.amber : t.color.cornsilk
|
||||
const fg = active ? t.color.accent : t.color.text
|
||||
|
||||
return (
|
||||
<Text bold={active} color={fg} inverse={active} wrap="truncate-end">
|
||||
{' '}
|
||||
<Text color={active ? fg : t.color.dim}>{formatRowId(index)} </Text>
|
||||
<Text color={active ? fg : t.color.muted}>{formatRowId(index)} </Text>
|
||||
{indentFor(node.item.depth)}
|
||||
{heatMarker ? <Text color={heatMarker}>▍</Text> : null}
|
||||
<Text color={active ? fg : color}>{glyph}</Text> {goal}
|
||||
<Text color={active ? fg : t.color.dim}>
|
||||
<Text color={active ? fg : t.color.muted}>
|
||||
{toolsCount}
|
||||
{kids}
|
||||
{trailing}
|
||||
@@ -585,16 +585,16 @@ function DiffPane({
|
||||
}) {
|
||||
return (
|
||||
<Box flexDirection="column" width={width}>
|
||||
<Text bold color={t.color.cornsilk}>
|
||||
<Text bold color={t.color.text}>
|
||||
{label}
|
||||
</Text>
|
||||
|
||||
<Text color={t.color.dim} wrap="truncate-end">
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{snapshot.label}
|
||||
</Text>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={t.color.dim} wrap="truncate-end">
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{formatSummary(totals)}
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -606,7 +606,7 @@ function DiffPane({
|
||||
const { color, glyph } = statusGlyph(s, t)
|
||||
|
||||
return (
|
||||
<Text color={t.color.dim} key={s.id} wrap="truncate-end">
|
||||
<Text color={t.color.muted} key={s.id} wrap="truncate-end">
|
||||
<Text color={color}>{glyph}</Text> {s.goal || 'subagent'}
|
||||
</Text>
|
||||
)
|
||||
@@ -644,10 +644,10 @@ function DiffView({
|
||||
return (
|
||||
<Box flexDirection="column" flexGrow={1} paddingX={1} paddingY={1}>
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text bold color={t.color.bronze}>
|
||||
<Text bold color={t.color.border}>
|
||||
Replay diff
|
||||
</Text>
|
||||
<Text color={t.color.dim}>baseline vs candidate · esc/q close</Text>
|
||||
<Text color={t.color.muted}>baseline vs candidate · esc/q close</Text>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
@@ -657,24 +657,22 @@ function DiffView({
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text bold color={t.color.amber}>
|
||||
<Text bold color={t.color.accent}>
|
||||
Δ
|
||||
</Text>
|
||||
|
||||
<Text color={t.color.cornsilk}>
|
||||
<Text color={t.color.text}>
|
||||
{diffMetricLine('agents', aTotals.descendantCount, bTotals.descendantCount, round)}
|
||||
</Text>
|
||||
<Text color={t.color.cornsilk}>{diffMetricLine('tools', aTotals.totalTools, bTotals.totalTools, round)}</Text>
|
||||
<Text color={t.color.cornsilk}>
|
||||
<Text color={t.color.text}>{diffMetricLine('tools', aTotals.totalTools, bTotals.totalTools, round)}</Text>
|
||||
<Text color={t.color.text}>
|
||||
{diffMetricLine('depth', aTotals.maxDepthFromHere, bTotals.maxDepthFromHere, round)}
|
||||
</Text>
|
||||
<Text color={t.color.cornsilk}>
|
||||
<Text color={t.color.text}>
|
||||
{diffMetricLine('duration', aTotals.totalDuration, bTotals.totalDuration, n => `${n.toFixed(1)}s`)}
|
||||
</Text>
|
||||
<Text color={t.color.cornsilk}>
|
||||
{diffMetricLine('tokens', sumTokens(aTotals), sumTokens(bTotals), fmtTokens)}
|
||||
</Text>
|
||||
<Text color={t.color.cornsilk}>{diffMetricLine('cost', aTotals.costUsd, bTotals.costUsd, dollars)}</Text>
|
||||
<Text color={t.color.text}>{diffMetricLine('tokens', sumTokens(aTotals), sumTokens(bTotals), fmtTokens)}</Text>
|
||||
<Text color={t.color.text}>{diffMetricLine('cost', aTotals.costUsd, bTotals.costUsd, dollars)}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
@@ -985,11 +983,11 @@ export function AgentsOverlay({ gw, initialHistoryIndex = 0, onClose, t }: Agent
|
||||
<Box alignItems="stretch" flexDirection="column" flexGrow={1} paddingX={1} paddingY={1}>
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text wrap="truncate-end">
|
||||
<Text bold color={replayMode ? t.color.bronze : t.color.gold}>
|
||||
<Text bold color={replayMode ? t.color.border : t.color.primary}>
|
||||
{title}
|
||||
</Text>
|
||||
{metaLine ? (
|
||||
<Text color={t.color.dim}>
|
||||
<Text color={t.color.muted}>
|
||||
{' '}
|
||||
{metaLine}
|
||||
</Text>
|
||||
@@ -999,7 +997,7 @@ export function AgentsOverlay({ gw, initialHistoryIndex = 0, onClose, t }: Agent
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
<Text color={t.color.dim}>No subagents this turn. Trigger delegate_task to populate the tree.</Text>
|
||||
<Text color={t.color.muted}>No subagents this turn. Trigger delegate_task to populate the tree.</Text>
|
||||
</Box>
|
||||
) : mode === 'list' ? (
|
||||
<Box flexDirection="column" flexGrow={1} flexShrink={1} minHeight={0}>
|
||||
@@ -1034,17 +1032,17 @@ export function AgentsOverlay({ gw, initialHistoryIndex = 0, onClose, t }: Agent
|
||||
)}
|
||||
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{flash ? <Text color={t.color.amber}>{flash}</Text> : null}
|
||||
{flash ? <Text color={t.color.accent}>{flash}</Text> : null}
|
||||
|
||||
{mode === 'list' ? (
|
||||
<Text color={t.color.dim}>
|
||||
<Text color={t.color.muted}>
|
||||
↑↓/jk move · g/G top/bottom · Enter/→ open detail{controlsHint} · s sort:{SORT_LABEL[sort]} · f filter:
|
||||
{FILTER_LABEL[filter]}
|
||||
{history.length > 0 ? ` · [ / ] history ${historyIndex}/${history.length}` : ''}
|
||||
{' · q close'}
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={t.color.dim}>
|
||||
<Text color={t.color.muted}>
|
||||
↑↓/jk scroll · PgUp/PgDn page · g/G top/bottom · Esc/← back to list{controlsHint} · q close
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Box, type ScrollBoxHandle, Text } from '@hermes/ink'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type ReactNode, type RefObject, useEffect, useMemo, useState } from 'react'
|
||||
import unicodeSpinners from 'unicode-animations'
|
||||
|
||||
import { $delegationState } from '../app/delegationStore.js'
|
||||
import type { IndicatorStyle } from '../app/interfaces.js'
|
||||
import { useTurnSelector } from '../app/turnStore.js'
|
||||
import { $uiState } from '../app/uiStore.js'
|
||||
import { FACES } from '../content/faces.js'
|
||||
import { VERBS } from '../content/verbs.js'
|
||||
import { fmtDuration } from '../domain/messages.js'
|
||||
@@ -17,30 +20,103 @@ import type { Msg, Usage } from '../types.js'
|
||||
const FACE_TICK_MS = 2500
|
||||
const HEART_COLORS = ['#ff5fa2', '#ff4d6d']
|
||||
|
||||
// Compact alternates for the `emoji` and `ascii` indicator styles.
|
||||
// Each entry is a fixed-width (display-width) glyph.
|
||||
const EMOJI_FRAMES = ['⚕ ', '🌀', '🤔', '✨', '🍵', '🔮']
|
||||
const ASCII_FRAMES = ['|', '/', '-', '\\']
|
||||
|
||||
// Faster tick for spinner-style indicators — they read as motion only
|
||||
// at frame rates closer to their authored interval.
|
||||
const SPINNER_TICK_MS = 100
|
||||
|
||||
interface IndicatorRender {
|
||||
frame: string
|
||||
intervalMs: number
|
||||
// When false, FaceTicker hides the rotating verb and just shows the
|
||||
// glyph + duration. Lets `unicode` stay minimal while the other
|
||||
// styles keep the verb-rotation flavour users associate with the
|
||||
// running… status.
|
||||
showVerb: boolean
|
||||
}
|
||||
|
||||
const renderIndicator = (style: IndicatorStyle, tick: number): IndicatorRender => {
|
||||
if (style === 'kaomoji') {
|
||||
return { frame: FACES[tick % FACES.length] ?? '', intervalMs: FACE_TICK_MS, showVerb: true }
|
||||
}
|
||||
|
||||
if (style === 'emoji') {
|
||||
return {
|
||||
frame: EMOJI_FRAMES[tick % EMOJI_FRAMES.length] ?? '⚕ ',
|
||||
intervalMs: SPINNER_TICK_MS * 6,
|
||||
showVerb: true
|
||||
}
|
||||
}
|
||||
|
||||
if (style === 'ascii') {
|
||||
return {
|
||||
frame: ASCII_FRAMES[tick % ASCII_FRAMES.length] ?? '|',
|
||||
intervalMs: SPINNER_TICK_MS,
|
||||
showVerb: true
|
||||
}
|
||||
}
|
||||
|
||||
// 'unicode' — braille spinner (fixed 1-col). Authored interval is
|
||||
// ~80ms; honour it but bound below at a safe minimum so React
|
||||
// re-renders stay reasonable. This style is for users who want
|
||||
// the cleanest possible status, so no verb rotation either.
|
||||
const spinner = unicodeSpinners.braille
|
||||
const frame = spinner.frames[tick % spinner.frames.length] ?? '⠋'
|
||||
|
||||
return { frame, intervalMs: Math.max(SPINNER_TICK_MS, spinner.interval), showVerb: false }
|
||||
}
|
||||
|
||||
function FaceTicker({ color, startedAt }: { color: string; startedAt?: null | number }) {
|
||||
const ui = useStore($uiState)
|
||||
const style = ui.indicatorStyle
|
||||
const [tick, setTick] = useState(() => Math.floor(Math.random() * 1000))
|
||||
const [verbTick, setVerbTick] = useState(() => Math.floor(Math.random() * VERBS.length))
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
|
||||
// Pre-compute cadence + verb-visibility for the active style so an
|
||||
// `/indicator` switch re-arms the interval (and skips the verb timer
|
||||
// for verb-less styles like `unicode`) without leaving the previous
|
||||
// timer dangling.
|
||||
const { intervalMs, showVerb } = renderIndicator(style, 0)
|
||||
|
||||
useEffect(() => {
|
||||
const face = setInterval(() => setTick(n => n + 1), FACE_TICK_MS)
|
||||
const glyph = setInterval(() => setTick(n => n + 1), intervalMs)
|
||||
const clock = setInterval(() => setNow(Date.now()), 1000)
|
||||
// Verb timer is gated on `showVerb` — `unicode` style hides the verb
|
||||
// entirely, so cycling `verbTick` would be an avoidable re-render.
|
||||
const verb = showVerb ? setInterval(() => setVerbTick(n => n + 1), FACE_TICK_MS) : null
|
||||
|
||||
return () => {
|
||||
clearInterval(face)
|
||||
clearInterval(glyph)
|
||||
clearInterval(clock)
|
||||
|
||||
if (verb !== null) {
|
||||
clearInterval(verb)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
}, [intervalMs, showVerb])
|
||||
|
||||
const { frame } = renderIndicator(style, tick)
|
||||
const verb = VERBS[verbTick % VERBS.length] ?? ''
|
||||
const verbSegment = showVerb ? ` ${verb}…` : ''
|
||||
const durationSegment = startedAt ? ` · ${fmtDuration(now - startedAt)}` : ''
|
||||
|
||||
return (
|
||||
<Text color={color}>
|
||||
{FACES[tick % FACES.length]} {VERBS[tick % VERBS.length]}…{startedAt ? ` · ${fmtDuration(now - startedAt)}` : ''}
|
||||
{frame}
|
||||
{verbSegment}
|
||||
{durationSegment}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
function ctxBarColor(pct: number | undefined, t: Theme) {
|
||||
if (pct == null) {
|
||||
return t.color.dim
|
||||
return t.color.muted
|
||||
}
|
||||
|
||||
if (pct >= 95) {
|
||||
@@ -93,7 +169,7 @@ function SpawnHud({ t }: { t: Theme }) {
|
||||
const concRatio = maxConc ? widestLevel / maxConc : 0
|
||||
const ratio = Math.max(depthRatio, concRatio)
|
||||
|
||||
const color = delegation.paused || ratio >= 1 ? t.color.error : ratio >= 0.66 ? t.color.warn : t.color.dim
|
||||
const color = delegation.paused || ratio >= 1 ? t.color.error : ratio >= 0.66 ? t.color.warn : t.color.muted
|
||||
|
||||
const pieces: string[] = []
|
||||
|
||||
@@ -162,21 +238,21 @@ const modelLabel = (model: string, effort?: string, fast?: boolean) =>
|
||||
|
||||
export function GoodVibesHeart({ tick, t }: { tick: number; t: Theme }) {
|
||||
const [active, setActive] = useState(false)
|
||||
const [color, setColor] = useState(t.color.amber)
|
||||
const [color, setColor] = useState(t.color.accent)
|
||||
|
||||
useEffect(() => {
|
||||
if (tick <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const palette = [...HEART_COLORS, t.color.amber]
|
||||
const palette = [t.color.error, t.color.warn, t.color.accent]
|
||||
setColor(palette[Math.floor(Math.random() * palette.length)]!)
|
||||
setActive(true)
|
||||
|
||||
const id = setTimeout(() => setActive(false), 650)
|
||||
|
||||
return () => clearTimeout(id)
|
||||
}, [t.color.amber, tick])
|
||||
}, [t.color.accent, tick])
|
||||
|
||||
if (!active) {
|
||||
return null
|
||||
@@ -217,23 +293,23 @@ export function StatusRule({
|
||||
return (
|
||||
<Box height={1}>
|
||||
<Box flexShrink={1} width={leftWidth}>
|
||||
<Text color={t.color.bronze} wrap="truncate-end">
|
||||
<Text color={t.color.border} wrap="truncate-end">
|
||||
{'─ '}
|
||||
{busy ? (
|
||||
<FaceTicker color={statusColor} startedAt={turnStartedAt} />
|
||||
) : (
|
||||
<Text color={statusColor}>{status}</Text>
|
||||
)}
|
||||
<Text color={t.color.dim}> │ {modelLabel(model, modelReasoningEffort, modelFast)}</Text>
|
||||
{ctxLabel ? <Text color={t.color.dim}> │ {ctxLabel}</Text> : null}
|
||||
<Text color={t.color.muted}> │ {modelLabel(model, modelReasoningEffort, modelFast)}</Text>
|
||||
{ctxLabel ? <Text color={t.color.muted}> │ {ctxLabel}</Text> : null}
|
||||
{bar ? (
|
||||
<Text color={t.color.dim}>
|
||||
<Text color={t.color.muted}>
|
||||
{' │ '}
|
||||
<Text color={barColor}>[{bar}]</Text> <Text color={barColor}>{pct != null ? `${pct}%` : ''}</Text>
|
||||
</Text>
|
||||
) : null}
|
||||
{sessionStartedAt ? (
|
||||
<Text color={t.color.dim}>
|
||||
<Text color={t.color.muted}>
|
||||
{' │ '}
|
||||
<SessionDuration startedAt={sessionStartedAt} />
|
||||
</Text>
|
||||
@@ -242,21 +318,21 @@ export function StatusRule({
|
||||
{voiceLabel ? (
|
||||
<Text
|
||||
color={
|
||||
voiceLabel.startsWith('●') ? t.color.error : voiceLabel.startsWith('◉') ? t.color.warn : t.color.dim
|
||||
voiceLabel.startsWith('●') ? t.color.error : voiceLabel.startsWith('◉') ? t.color.warn : t.color.muted
|
||||
}
|
||||
>
|
||||
{' │ '}
|
||||
{voiceLabel}
|
||||
</Text>
|
||||
) : null}
|
||||
{bgCount > 0 ? <Text color={t.color.dim}> │ {bgCount} bg</Text> : null}
|
||||
{bgCount > 0 ? <Text color={t.color.muted}> │ {bgCount} bg</Text> : null}
|
||||
{showCost && typeof usage.cost_usd === 'number' ? (
|
||||
<Text color={t.color.dim}> │ ${usage.cost_usd.toFixed(4)}</Text>
|
||||
<Text color={t.color.muted}> │ ${usage.cost_usd.toFixed(4)}</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Text color={t.color.bronze}> ─ </Text>
|
||||
<Text color={t.color.border}> ─ </Text>
|
||||
<Text color={t.color.label}>{cwdLabel}</Text>
|
||||
</Box>
|
||||
)
|
||||
@@ -301,8 +377,8 @@ 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.gold : hover ? t.color.amber : t.color.bronze
|
||||
const trackColor = hover ? t.color.bronze : t.color.dim
|
||||
const thumbColor = grab !== null ? t.color.primary : hover ? t.color.accent : t.color.border
|
||||
const trackColor = hover ? t.color.border : t.color.muted
|
||||
|
||||
const jump = (row: number, offset: number) => {
|
||||
if (!s || !scrollable) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AlternateScreen, Box, NoSelect, ScrollBox, Text } from '@hermes/ink'
|
||||
import { AlternateScreen, Box, NoSelect, ScrollBox, stringWidth, Text } from '@hermes/ink'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { Fragment, memo, useMemo, useRef } from 'react'
|
||||
|
||||
@@ -124,8 +124,10 @@ const ComposerPane = memo(function ComposerPane({
|
||||
const ui = useStore($uiState)
|
||||
const isBlocked = useStore($isBlocked)
|
||||
const sh = (composer.inputBuf[0] ?? composer.input).startsWith('!')
|
||||
const pw = 2
|
||||
const inputColumns = stableComposerColumns(composer.cols, pw)
|
||||
const promptText = sh ? '$' : ui.theme.brand.prompt
|
||||
const promptLabel = `${promptText} `
|
||||
const promptWidth = Math.max(1, stringWidth(promptLabel))
|
||||
const inputColumns = stableComposerColumns(composer.cols, promptWidth)
|
||||
const inputHeight = inputVisualHeight(composer.input, inputColumns)
|
||||
const inputMouseRef = useRef<null | TextInputMouseApi>(null)
|
||||
|
||||
@@ -146,7 +148,7 @@ const ComposerPane = memo(function ComposerPane({
|
||||
}
|
||||
|
||||
e.stopImmediatePropagation?.()
|
||||
inputMouseRef.current?.dragAt(e.localRow ?? 0, (e.localCol ?? 0) - pw)
|
||||
inputMouseRef.current?.dragAt(e.localRow ?? 0, (e.localCol ?? 0) - promptWidth)
|
||||
}
|
||||
|
||||
// Spacer rows live on a different vertical origin; only the column is
|
||||
@@ -158,7 +160,7 @@ const ComposerPane = memo(function ComposerPane({
|
||||
}
|
||||
|
||||
e.stopImmediatePropagation?.()
|
||||
inputMouseRef.current?.dragAt(0, (e.localCol ?? 0) - pw)
|
||||
inputMouseRef.current?.dragAt(0, (e.localCol ?? 0) - promptWidth)
|
||||
}
|
||||
|
||||
const endInputDrag = () => inputMouseRef.current?.end()
|
||||
@@ -183,13 +185,13 @@ const ComposerPane = memo(function ComposerPane({
|
||||
/>
|
||||
|
||||
{ui.bgTasks.size > 0 && (
|
||||
<Text color={ui.theme.color.dim}>
|
||||
<Text color={ui.theme.color.muted}>
|
||||
{ui.bgTasks.size} background {ui.bgTasks.size === 1 ? 'task' : 'tasks'} running
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{status.showStickyPrompt ? (
|
||||
<Text color={ui.theme.color.dim} wrap="truncate-end">
|
||||
<Text color={ui.theme.color.muted} wrap="truncate-end">
|
||||
<Text color={ui.theme.color.label}>↳ </Text>
|
||||
|
||||
{status.stickyPrompt}
|
||||
@@ -214,21 +216,26 @@ const ComposerPane = memo(function ComposerPane({
|
||||
<>
|
||||
{composer.inputBuf.map((line, i) => (
|
||||
<Box key={i}>
|
||||
<Box width={2}>
|
||||
<Text color={ui.theme.color.dim}>{i === 0 ? `${ui.theme.brand.prompt} ` : ' '}</Text>
|
||||
<Box width={promptWidth}>
|
||||
<Text color={ui.theme.color.muted}>{i === 0 ? promptLabel : ' '.repeat(promptWidth)}</Text>
|
||||
</Box>
|
||||
|
||||
<Text color={ui.theme.color.cornsilk}>{line || ' '}</Text>
|
||||
<Text color={ui.theme.color.text}>{line || ' '}</Text>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
<Box onMouseDown={captureInputDrag} onMouseDrag={dragFromPromptRow} onMouseUp={endInputDrag} position="relative">
|
||||
<Box width={pw}>
|
||||
<Box
|
||||
onMouseDown={captureInputDrag}
|
||||
onMouseDrag={dragFromPromptRow}
|
||||
onMouseUp={endInputDrag}
|
||||
position="relative"
|
||||
>
|
||||
<Box width={promptWidth}>
|
||||
{sh ? (
|
||||
<Text color={ui.theme.color.shellDollar}>$ </Text>
|
||||
<Text color={ui.theme.color.shellDollar}>{promptLabel}</Text>
|
||||
) : (
|
||||
<Text bold color={ui.theme.color.prompt}>
|
||||
{composer.inputBuf.length ? ' ' : `${ui.theme.brand.prompt} `}
|
||||
{composer.inputBuf.length ? ' '.repeat(promptWidth) : promptLabel}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
@@ -254,7 +261,7 @@ const ComposerPane = memo(function ComposerPane({
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{!composer.empty && !ui.sid && <Text color={ui.theme.color.dim}>⚕ {ui.status}</Text>}
|
||||
{!composer.empty && !ui.sid && <Text color={ui.theme.color.muted}>⚕ {ui.status}</Text>}
|
||||
|
||||
<StatusRulePane at="bottom" composer={composer} status={status} />
|
||||
</NoSelect>
|
||||
@@ -319,6 +326,7 @@ export const AppLayout = memo(function AppLayout({
|
||||
transcript
|
||||
}: AppLayoutProps) {
|
||||
const overlay = useStore($overlayState)
|
||||
const ui = useStore($uiState)
|
||||
|
||||
// Inline mode skips AlternateScreen so the host terminal's native
|
||||
// scrollback captures rows scrolled off the top; composer + progress
|
||||
@@ -359,7 +367,7 @@ export const AppLayout = memo(function AppLayout({
|
||||
|
||||
{SHOW_FPS && (
|
||||
<Box flexShrink={0} justifyContent="flex-end" paddingRight={1}>
|
||||
<FpsOverlay />
|
||||
<FpsOverlay t={ui.theme} />
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -119,7 +119,7 @@ export function FloatingOverlays({
|
||||
return (
|
||||
<Box alignItems="flex-start" bottom="100%" flexDirection="column" left={0} position="absolute" right={0}>
|
||||
{overlay.picker && (
|
||||
<FloatBox color={ui.theme.color.bronze}>
|
||||
<FloatBox color={ui.theme.color.border}>
|
||||
<SessionPicker
|
||||
gw={gw}
|
||||
onCancel={() => patchOverlayState({ picker: false })}
|
||||
@@ -130,7 +130,7 @@ export function FloatingOverlays({
|
||||
)}
|
||||
|
||||
{overlay.modelPicker && (
|
||||
<FloatBox color={ui.theme.color.bronze}>
|
||||
<FloatBox color={ui.theme.color.border}>
|
||||
<ModelPicker
|
||||
gw={gw}
|
||||
onCancel={() => patchOverlayState({ modelPicker: false })}
|
||||
@@ -142,17 +142,17 @@ export function FloatingOverlays({
|
||||
)}
|
||||
|
||||
{overlay.skillsHub && (
|
||||
<FloatBox color={ui.theme.color.bronze}>
|
||||
<FloatBox color={ui.theme.color.border}>
|
||||
<SkillsHub gw={gw} onClose={() => patchOverlayState({ skillsHub: false })} t={ui.theme} />
|
||||
</FloatBox>
|
||||
)}
|
||||
|
||||
{overlay.pager && (
|
||||
<FloatBox color={ui.theme.color.bronze}>
|
||||
<FloatBox color={ui.theme.color.border}>
|
||||
<Box flexDirection="column" paddingX={1} paddingY={1}>
|
||||
{overlay.pager.title && (
|
||||
<Box justifyContent="center" marginBottom={1}>
|
||||
<Text bold color={ui.theme.color.gold}>
|
||||
<Text bold color={ui.theme.color.primary}>
|
||||
{overlay.pager.title}
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -174,7 +174,7 @@ export function FloatingOverlays({
|
||||
)}
|
||||
|
||||
{!!completions.length && (
|
||||
<FloatBox color={ui.theme.color.gold}>
|
||||
<FloatBox color={ui.theme.color.primary}>
|
||||
<Box flexDirection="column" width={Math.max(28, cols - 6)}>
|
||||
{completions.slice(start, start + viewportSize).map((item, i) => {
|
||||
const active = start + i === compIdx
|
||||
@@ -190,7 +190,7 @@ export function FloatingOverlays({
|
||||
{' '}
|
||||
{item.display}
|
||||
</Text>
|
||||
{item.meta ? <Text color={ui.theme.color.dim}> {item.meta}</Text> : null}
|
||||
{item.meta ? <Text color={ui.theme.color.muted}> {item.meta}</Text> : null}
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -26,12 +26,12 @@ export function Banner({ t }: { t: Theme }) {
|
||||
{cols >= (t.bannerLogo ? artWidth(logoLines) : LOGO_WIDTH) ? (
|
||||
<ArtLines lines={logoLines} />
|
||||
) : (
|
||||
<Text bold color={t.color.gold}>
|
||||
<Text bold color={t.color.primary}>
|
||||
{t.brand.icon} NOUS HERMES
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Text color={t.color.dim}>{t.brand.icon} Nous Research · Messenger of the Digital Gods</Text>
|
||||
<Text color={t.color.muted}>{t.brand.icon} Nous Research · Messenger of the Digital Gods</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -70,19 +70,19 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) {
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text bold color={t.color.amber}>
|
||||
<Text bold color={t.color.accent}>
|
||||
Available {title}
|
||||
</Text>
|
||||
|
||||
{shown.map(([k, vs]) => (
|
||||
<Text key={k} wrap="truncate">
|
||||
<Text color={t.color.dim}>{strip(k)}: </Text>
|
||||
<Text color={t.color.cornsilk}>{truncLine(strip(k) + ': ', vs)}</Text>
|
||||
<Text color={t.color.muted}>{strip(k)}: </Text>
|
||||
<Text color={t.color.text}>{truncLine(strip(k) + ': ', vs)}</Text>
|
||||
</Text>
|
||||
))}
|
||||
|
||||
{overflow > 0 && (
|
||||
<Text color={t.color.dim}>
|
||||
<Text color={t.color.muted}>
|
||||
(and {overflow} {overflowLabel})
|
||||
</Text>
|
||||
)}
|
||||
@@ -91,18 +91,18 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Box borderColor={t.color.bronze} borderStyle="round" marginBottom={1} paddingX={2} paddingY={1}>
|
||||
<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 />
|
||||
|
||||
<Text color={t.color.amber}>
|
||||
<Text color={t.color.accent}>
|
||||
{info.model.split('/').pop()}
|
||||
<Text color={t.color.dim}> · Nous Research</Text>
|
||||
<Text color={t.color.muted}> · Nous Research</Text>
|
||||
</Text>
|
||||
|
||||
<Text color={t.color.dim} wrap="truncate-end">
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{info.cwd || process.cwd()}
|
||||
</Text>
|
||||
|
||||
@@ -117,7 +117,7 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) {
|
||||
|
||||
<Box flexDirection="column" width={w}>
|
||||
<Box justifyContent="center" marginBottom={1}>
|
||||
<Text bold color={t.color.gold}>
|
||||
<Text bold color={t.color.primary}>
|
||||
{t.brand.name}
|
||||
{info.version ? ` v${info.version}` : ''}
|
||||
{info.release_date ? ` (${info.release_date})` : ''}
|
||||
@@ -129,17 +129,17 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) {
|
||||
|
||||
{info.mcp_servers && info.mcp_servers.length > 0 && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text bold color={t.color.amber}>
|
||||
<Text bold color={t.color.accent}>
|
||||
MCP Servers
|
||||
</Text>
|
||||
|
||||
{info.mcp_servers.map(s => (
|
||||
<Text key={s.name} wrap="truncate">
|
||||
<Text color={t.color.dim}>{` ${s.name} `}</Text>
|
||||
<Text color={t.color.dim}>{`[${s.transport}]`}</Text>
|
||||
<Text color={t.color.dim}>: </Text>
|
||||
<Text color={t.color.muted}>{` ${s.name} `}</Text>
|
||||
<Text color={t.color.muted}>{`[${s.transport}]`}</Text>
|
||||
<Text color={t.color.muted}>: </Text>
|
||||
{s.connected ? (
|
||||
<Text color={t.color.cornsilk}>
|
||||
<Text color={t.color.text}>
|
||||
{s.tools} tool{s.tools === 1 ? '' : 's'}
|
||||
</Text>
|
||||
) : (
|
||||
@@ -152,12 +152,12 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) {
|
||||
|
||||
<Text />
|
||||
|
||||
<Text color={t.color.cornsilk}>
|
||||
<Text color={t.color.text}>
|
||||
{flat(info.tools).length} tools{' · '}
|
||||
{flat(info.skills).length} skills
|
||||
{info.mcp_servers?.length ? ` · ${info.mcp_servers.length} MCP` : ''}
|
||||
{' · '}
|
||||
<Text color={t.color.dim}>/help for commands</Text>
|
||||
<Text color={t.color.muted}>/help for commands</Text>
|
||||
</Text>
|
||||
|
||||
{typeof info.update_behind === 'number' && info.update_behind > 0 && (
|
||||
@@ -183,9 +183,9 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) {
|
||||
|
||||
export function Panel({ sections, t, title }: PanelProps) {
|
||||
return (
|
||||
<Box borderColor={t.color.bronze} borderStyle="round" flexDirection="column" paddingX={2} paddingY={1}>
|
||||
<Box borderColor={t.color.border} borderStyle="round" flexDirection="column" paddingX={2} paddingY={1}>
|
||||
<Box justifyContent="center" marginBottom={1}>
|
||||
<Text bold color={t.color.gold}>
|
||||
<Text bold color={t.color.primary}>
|
||||
{title}
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -193,25 +193,25 @@ export function Panel({ sections, t, title }: PanelProps) {
|
||||
{sections.map((sec, si) => (
|
||||
<Box flexDirection="column" key={si} marginTop={si > 0 ? 1 : 0}>
|
||||
{sec.title && (
|
||||
<Text bold color={t.color.amber}>
|
||||
<Text bold color={t.color.accent}>
|
||||
{sec.title}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{sec.rows?.map(([k, v], ri) => (
|
||||
<Text key={ri} wrap="truncate">
|
||||
<Text color={t.color.dim}>{k.padEnd(20)}</Text>
|
||||
<Text color={t.color.cornsilk}>{v}</Text>
|
||||
<Text color={t.color.muted}>{k.padEnd(20)}</Text>
|
||||
<Text color={t.color.text}>{v}</Text>
|
||||
</Text>
|
||||
))}
|
||||
|
||||
{sec.items?.map((item, ii) => (
|
||||
<Text color={t.color.cornsilk} key={ii} wrap="truncate">
|
||||
<Text color={t.color.text} key={ii} wrap="truncate">
|
||||
{item}
|
||||
</Text>
|
||||
))}
|
||||
|
||||
{sec.text && <Text color={t.color.dim}>{sec.text}</Text>}
|
||||
{sec.text && <Text color={t.color.muted}>{sec.text}</Text>}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
@@ -5,23 +5,25 @@ import { useStore } from '@nanostores/react'
|
||||
|
||||
import { SHOW_FPS } from '../config/env.js'
|
||||
import { $fpsState } from '../lib/fpsStore.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
|
||||
const fpsColor = (fps: number) => (fps >= 50 ? 'green' : fps >= 30 ? 'yellow' : 'red')
|
||||
const fpsColor = (fps: number, t: Theme) =>
|
||||
fps >= 50 ? t.color.statusGood : fps >= 30 ? t.color.statusWarn : t.color.error
|
||||
|
||||
export function FpsOverlay() {
|
||||
export function FpsOverlay({ t }: { t: Theme }) {
|
||||
if (!SHOW_FPS) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <FpsOverlayInner />
|
||||
return <FpsOverlayInner t={t} />
|
||||
}
|
||||
|
||||
function FpsOverlayInner() {
|
||||
function FpsOverlayInner({ t }: { t: Theme }) {
|
||||
const { fps, lastDurationMs, totalFrames } = useStore($fpsState)
|
||||
|
||||
// Zero-pad widths so digit churn doesn't jitter the corner.
|
||||
return (
|
||||
<Text color={fpsColor(fps)}>
|
||||
<Text color={fpsColor(fps, t)}>
|
||||
{fps.toFixed(1).padStart(5)}fps · {lastDurationMs.toFixed(1).padStart(5)}ms · #{totalFrames}
|
||||
</Text>
|
||||
)
|
||||
|
||||
@@ -72,7 +72,7 @@ const autolinkUrl = (raw: string) =>
|
||||
|
||||
const renderAutolink = (k: number, t: Theme, raw: string) => (
|
||||
<Link key={k} url={autolinkUrl(raw)}>
|
||||
<Text color={t.color.amber} underline>
|
||||
<Text color={t.color.accent} underline>
|
||||
{raw.replace(/^mailto:/, '')}
|
||||
</Text>
|
||||
</Link>
|
||||
@@ -113,7 +113,7 @@ const renderTable = (k: number, rows: string[][], t: Theme) => {
|
||||
<Fragment key={ri}>
|
||||
<Box>
|
||||
{widths.map((w, ci) => (
|
||||
<Text bold={ri === 0} color={ri === 0 ? t.color.amber : undefined} key={ci}>
|
||||
<Text bold={ri === 0} color={ri === 0 ? t.color.accent : undefined} key={ci}>
|
||||
<MdInline t={t} text={row[ci] ?? ''} />
|
||||
{' '.repeat(Math.max(0, w - stripInlineMarkup(row[ci] ?? '').length))}
|
||||
{ci < widths.length - 1 ? ' ' : ''}
|
||||
@@ -121,7 +121,7 @@ const renderTable = (k: number, rows: string[][], t: Theme) => {
|
||||
))}
|
||||
</Box>
|
||||
{ri === 0 && rows.length > 1 ? (
|
||||
<Text color={t.color.dim} dimColor>
|
||||
<Text color={t.color.muted} dimColor>
|
||||
{sep}
|
||||
</Text>
|
||||
) : null}
|
||||
@@ -146,14 +146,14 @@ function MdInline({ t, text }: { t: Theme; text: string }) {
|
||||
|
||||
if (m[1] && m[2]) {
|
||||
parts.push(
|
||||
<Text color={t.color.dim} key={parts.length}>
|
||||
<Text color={t.color.muted} key={parts.length}>
|
||||
[image: {m[1]}] {m[2]}
|
||||
</Text>
|
||||
)
|
||||
} else if (m[3] && m[4]) {
|
||||
parts.push(
|
||||
<Link key={parts.length} url={m[4]}>
|
||||
<Text color={t.color.amber} underline>
|
||||
<Text color={t.color.accent} underline>
|
||||
{m[3]}
|
||||
</Text>
|
||||
</Link>
|
||||
@@ -168,7 +168,7 @@ function MdInline({ t, text }: { t: Theme; text: string }) {
|
||||
)
|
||||
} else if (m[7]) {
|
||||
parts.push(
|
||||
<Text color={t.color.amber} dimColor key={parts.length}>
|
||||
<Text color={t.color.accent} dimColor key={parts.length}>
|
||||
{m[7]}
|
||||
</Text>
|
||||
)
|
||||
@@ -192,19 +192,19 @@ function MdInline({ t, text }: { t: Theme; text: string }) {
|
||||
)
|
||||
} else if (m[13]) {
|
||||
parts.push(
|
||||
<Text color={t.color.dim} key={parts.length}>
|
||||
<Text color={t.color.muted} key={parts.length}>
|
||||
[{m[13]}]
|
||||
</Text>
|
||||
)
|
||||
} else if (m[14]) {
|
||||
parts.push(
|
||||
<Text color={t.color.dim} key={parts.length}>
|
||||
<Text color={t.color.muted} key={parts.length}>
|
||||
^{m[14]}
|
||||
</Text>
|
||||
)
|
||||
} else if (m[15]) {
|
||||
parts.push(
|
||||
<Text color={t.color.dim} key={parts.length}>
|
||||
<Text color={t.color.muted} key={parts.length}>
|
||||
_{m[15]}
|
||||
</Text>
|
||||
)
|
||||
@@ -324,11 +324,11 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
if (media) {
|
||||
start('paragraph')
|
||||
nodes.push(
|
||||
<Text color={t.color.dim} key={key}>
|
||||
<Text color={t.color.muted} key={key}>
|
||||
{'▸ '}
|
||||
|
||||
<Link url={/^(?:\/|[a-z]:[\\/])/i.test(media) ? `file://${media}` : media}>
|
||||
<Text color={t.color.amber} underline>
|
||||
<Text color={t.color.accent} underline>
|
||||
{media}
|
||||
</Text>
|
||||
</Link>
|
||||
@@ -375,7 +375,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
|
||||
nodes.push(
|
||||
<Box flexDirection="column" key={key} paddingLeft={2}>
|
||||
{lang && !isDiff && <Text color={t.color.dim}>{'─ ' + lang}</Text>}
|
||||
{lang && !isDiff && <Text color={t.color.muted}>{'─ ' + lang}</Text>}
|
||||
|
||||
{block.map((l, j) => {
|
||||
if (highlighted) {
|
||||
@@ -401,7 +401,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
return (
|
||||
<Text
|
||||
backgroundColor={add ? t.color.diffAdded : del ? t.color.diffRemoved : undefined}
|
||||
color={add ? t.color.diffAddedWord : del ? t.color.diffRemovedWord : hunk ? t.color.dim : undefined}
|
||||
color={add ? t.color.diffAddedWord : del ? t.color.diffRemovedWord : hunk ? t.color.muted : undefined}
|
||||
dimColor={isDiff && !add && !del && !hunk && l.startsWith(' ')}
|
||||
key={j}
|
||||
>
|
||||
@@ -432,10 +432,10 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
|
||||
nodes.push(
|
||||
<Box flexDirection="column" key={key} paddingLeft={2}>
|
||||
<Text color={t.color.dim}>─ math</Text>
|
||||
<Text color={t.color.muted}>─ math</Text>
|
||||
|
||||
{block.map((l, j) => (
|
||||
<Text color={t.color.amber} key={j}>
|
||||
<Text color={t.color.accent} key={j}>
|
||||
{l}
|
||||
</Text>
|
||||
))}
|
||||
@@ -450,7 +450,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
if (heading) {
|
||||
start('heading')
|
||||
nodes.push(
|
||||
<Text bold color={t.color.amber} key={key}>
|
||||
<Text bold color={t.color.accent} key={key}>
|
||||
{heading}
|
||||
</Text>
|
||||
)
|
||||
@@ -462,7 +462,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
if (i + 1 < lines.length && SETEXT_RE.test(lines[i + 1]!)) {
|
||||
start('heading')
|
||||
nodes.push(
|
||||
<Text bold color={t.color.amber} key={key}>
|
||||
<Text bold color={t.color.accent} key={key}>
|
||||
{line.trim()}
|
||||
</Text>
|
||||
)
|
||||
@@ -474,7 +474,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
if (HR_RE.test(line)) {
|
||||
start('rule')
|
||||
nodes.push(
|
||||
<Text color={t.color.dim} key={key}>
|
||||
<Text color={t.color.muted} key={key}>
|
||||
{'─'.repeat(36)}
|
||||
</Text>
|
||||
)
|
||||
@@ -488,7 +488,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
if (footnote) {
|
||||
start('list')
|
||||
nodes.push(
|
||||
<Text color={t.color.dim} key={key}>
|
||||
<Text color={t.color.muted} key={key}>
|
||||
[{footnote[1]}] <MdInline t={t} text={footnote[2] ?? ''} />
|
||||
</Text>
|
||||
)
|
||||
@@ -497,7 +497,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
while (i < lines.length && /^\s{2,}\S/.test(lines[i]!)) {
|
||||
nodes.push(
|
||||
<Box key={`${key}-cont-${i}`} paddingLeft={2}>
|
||||
<Text color={t.color.dim}>
|
||||
<Text color={t.color.muted}>
|
||||
<MdInline t={t} text={lines[i]!.trim()} />
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -526,7 +526,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
|
||||
nodes.push(
|
||||
<Text key={`${key}-def-${i}`}>
|
||||
<Text color={t.color.dim}> · </Text>
|
||||
<Text color={t.color.muted}> · </Text>
|
||||
<MdInline t={t} text={def} />
|
||||
</Text>
|
||||
)
|
||||
@@ -546,7 +546,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
|
||||
nodes.push(
|
||||
<Text key={key}>
|
||||
<Text color={t.color.dim}>
|
||||
<Text color={t.color.muted}>
|
||||
{' '.repeat(indentDepth(bullet[1]!) * 2)}
|
||||
{marker}{' '}
|
||||
</Text>
|
||||
@@ -565,7 +565,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
start('list')
|
||||
nodes.push(
|
||||
<Text key={key}>
|
||||
<Text color={t.color.dim}>
|
||||
<Text color={t.color.muted}>
|
||||
{' '.repeat(indentDepth(numbered[1]!) * 2)}
|
||||
{numbered[2]}.{' '}
|
||||
</Text>
|
||||
@@ -593,7 +593,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
nodes.push(
|
||||
<Box flexDirection="column" key={key}>
|
||||
{quoteLines.map((ql, qi) => (
|
||||
<Text color={t.color.dim} key={qi}>
|
||||
<Text color={t.color.muted} key={qi}>
|
||||
{' '.repeat(Math.max(0, ql.depth - 1) * 2)}
|
||||
{'│ '}
|
||||
<MdInline t={t} text={ql.text} />
|
||||
@@ -630,7 +630,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
if (summary) {
|
||||
start('paragraph')
|
||||
nodes.push(
|
||||
<Text color={t.color.dim} key={key}>
|
||||
<Text color={t.color.muted} key={key}>
|
||||
▶ {summary}
|
||||
</Text>
|
||||
)
|
||||
@@ -642,7 +642,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
if (/^<\/?[^>]+>$/.test(line.trim())) {
|
||||
start('paragraph')
|
||||
nodes.push(
|
||||
<Text color={t.color.dim} key={key}>
|
||||
<Text color={t.color.muted} key={key}>
|
||||
{line.trim()}
|
||||
</Text>
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@ export function MaskedPrompt({ cols = 80, icon, label, onSubmit, sub, t }: Maske
|
||||
{icon} {label}
|
||||
</Text>
|
||||
|
||||
{sub && <Text color={t.color.dim}> {sub}</Text>}
|
||||
{sub && <Text color={t.color.muted}> {sub}</Text>}
|
||||
|
||||
<Box>
|
||||
<Text color={t.color.label}>{'> '}</Text>
|
||||
|
||||
@@ -80,13 +80,13 @@ export const MessageLine = memo(function MessageLine({
|
||||
const preview = compactPreview(stripped, maxChars) || '(empty tool result)'
|
||||
|
||||
return (
|
||||
<Box alignSelf="flex-start" borderColor={t.color.dim} borderStyle="round" marginLeft={3} paddingX={1}>
|
||||
<Box alignSelf="flex-start" borderColor={t.color.muted} borderStyle="round" marginLeft={3} paddingX={1}>
|
||||
{hasAnsi(msg.text) ? (
|
||||
<Text wrap="truncate-end">
|
||||
<Ansi>{msg.text}</Ansi>
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={t.color.dim} wrap="truncate-end">
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{preview}
|
||||
</Text>
|
||||
)}
|
||||
@@ -101,7 +101,7 @@ export const MessageLine = memo(function MessageLine({
|
||||
|
||||
const content = (() => {
|
||||
if (msg.kind === 'slash') {
|
||||
return <Text color={t.color.dim}>{msg.text}</Text>
|
||||
return <Text color={t.color.muted}>{msg.text}</Text>
|
||||
}
|
||||
|
||||
if (msg.role !== 'user' && hasAnsi(msg.text)) {
|
||||
@@ -125,7 +125,7 @@ export const MessageLine = memo(function MessageLine({
|
||||
return (
|
||||
<Text color={body}>
|
||||
{head}
|
||||
<Text color={t.color.dim} dimColor>
|
||||
<Text color={t.color.muted} dimColor>
|
||||
[long message]
|
||||
</Text>
|
||||
{rest.join('')}
|
||||
|
||||
@@ -146,7 +146,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
|
||||
})
|
||||
|
||||
if (loading) {
|
||||
return <Text color={t.color.dim}>loading models…</Text>
|
||||
return <Text color={t.color.muted}>loading models…</Text>
|
||||
}
|
||||
|
||||
if (err) {
|
||||
@@ -161,7 +161,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
|
||||
if (!providers.length) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={t.color.dim}>no authenticated providers</Text>
|
||||
<Text color={t.color.muted}>no authenticated providers</Text>
|
||||
<OverlayHint t={t}>Esc/q cancel</OverlayHint>
|
||||
</Box>
|
||||
)
|
||||
@@ -176,21 +176,21 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width={width}>
|
||||
<Text bold color={t.color.amber} wrap="truncate-end">
|
||||
<Text bold color={t.color.accent} wrap="truncate-end">
|
||||
Select provider (step 1/2)
|
||||
</Text>
|
||||
|
||||
<Text color={t.color.dim} wrap="truncate-end">
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
Full model IDs on the next step · Enter to continue
|
||||
</Text>
|
||||
|
||||
<Text color={t.color.dim} wrap="truncate-end">
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
Current: {currentModel || '(unknown)'}
|
||||
</Text>
|
||||
<Text color={t.color.label} wrap="truncate-end">
|
||||
{provider?.warning ? `warning: ${provider.warning}` : ' '}
|
||||
</Text>
|
||||
<Text color={t.color.dim} wrap="truncate-end">
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{offset > 0 ? ` ↑ ${offset} more` : ' '}
|
||||
</Text>
|
||||
|
||||
@@ -201,7 +201,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
|
||||
return row ? (
|
||||
<Text
|
||||
bold={providerIdx === idx}
|
||||
color={providerIdx === idx ? t.color.amber : t.color.dim}
|
||||
color={providerIdx === idx ? t.color.accent : t.color.muted}
|
||||
inverse={providerIdx === idx}
|
||||
key={providers[idx]?.slug ?? `row-${idx}`}
|
||||
wrap="truncate-end"
|
||||
@@ -210,17 +210,17 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
|
||||
{i + 1}. {row}
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={t.color.dim} key={`pad-${i}`} wrap="truncate-end">
|
||||
<Text color={t.color.muted} key={`pad-${i}`} wrap="truncate-end">
|
||||
{' '}
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
|
||||
<Text color={t.color.dim} wrap="truncate-end">
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{offset + VISIBLE < rows.length ? ` ↓ ${rows.length - offset - VISIBLE} more` : ' '}
|
||||
</Text>
|
||||
|
||||
<Text color={t.color.dim} wrap="truncate-end">
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
persist: {persistGlobal ? 'global' : 'session'} · g toggle
|
||||
</Text>
|
||||
<OverlayHint t={t}>↑/↓ select · Enter choose · 1-9,0 quick · Esc/q cancel</OverlayHint>
|
||||
@@ -232,17 +232,17 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width={width}>
|
||||
<Text bold color={t.color.amber} wrap="truncate-end">
|
||||
<Text bold color={t.color.accent} wrap="truncate-end">
|
||||
Select model (step 2/2)
|
||||
</Text>
|
||||
|
||||
<Text color={t.color.dim} wrap="truncate-end">
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{names[providerIdx] || '(unknown provider)'} · Esc back
|
||||
</Text>
|
||||
<Text color={t.color.label} wrap="truncate-end">
|
||||
{provider?.warning ? `warning: ${provider.warning}` : ' '}
|
||||
</Text>
|
||||
<Text color={t.color.dim} wrap="truncate-end">
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{offset > 0 ? ` ↑ ${offset} more` : ' '}
|
||||
</Text>
|
||||
|
||||
@@ -252,11 +252,11 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
|
||||
|
||||
if (!row) {
|
||||
return !models.length && i === 0 ? (
|
||||
<Text color={t.color.dim} key="empty" wrap="truncate-end">
|
||||
<Text color={t.color.muted} key="empty" wrap="truncate-end">
|
||||
no models listed for this provider
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={t.color.dim} key={`pad-${i}`} wrap="truncate-end">
|
||||
<Text color={t.color.muted} key={`pad-${i}`} wrap="truncate-end">
|
||||
{' '}
|
||||
</Text>
|
||||
)
|
||||
@@ -267,7 +267,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
|
||||
return (
|
||||
<Text
|
||||
bold={modelIdx === idx}
|
||||
color={modelIdx === idx ? t.color.amber : t.color.dim}
|
||||
color={modelIdx === idx ? t.color.accent : t.color.muted}
|
||||
inverse={modelIdx === idx}
|
||||
key={`${provider?.slug ?? 'prov'}:${idx}:${row}`}
|
||||
wrap="truncate-end"
|
||||
@@ -278,11 +278,11 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
|
||||
)
|
||||
})}
|
||||
|
||||
<Text color={t.color.dim} wrap="truncate-end">
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{offset + VISIBLE < models.length ? ` ↓ ${models.length - offset - VISIBLE} more` : ' '}
|
||||
</Text>
|
||||
|
||||
<Text color={t.color.dim} wrap="truncate-end">
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
persist: {persistGlobal ? 'global' : 'session'} · g toggle
|
||||
</Text>
|
||||
<OverlayHint t={t}>
|
||||
|
||||
@@ -20,7 +20,7 @@ export function useOverlayKeys({ disabled = false, onBack, onClose }: OverlayKey
|
||||
|
||||
export function OverlayHint({ children, t }: OverlayHintProps) {
|
||||
return (
|
||||
<Text color={t.color.dim} wrap="truncate-end">
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
|
||||
@@ -48,13 +48,13 @@ export function ApprovalPrompt({ onChoice, req, t }: ApprovalPromptProps) {
|
||||
|
||||
<Box flexDirection="column" paddingLeft={1}>
|
||||
{shown.map((line, i) => (
|
||||
<Text color={t.color.cornsilk} key={i} wrap="truncate-end">
|
||||
<Text color={t.color.text} key={i} wrap="truncate-end">
|
||||
{line || ' '}
|
||||
</Text>
|
||||
))}
|
||||
|
||||
{overflow > 0 ? (
|
||||
<Text color={t.color.dim}>
|
||||
<Text color={t.color.muted}>
|
||||
… +{overflow} more line{overflow === 1 ? '' : 's'} (full text above)
|
||||
</Text>
|
||||
) : null}
|
||||
@@ -64,14 +64,14 @@ export function ApprovalPrompt({ onChoice, req, t }: ApprovalPromptProps) {
|
||||
|
||||
{OPTS.map((o, i) => (
|
||||
<Text key={o}>
|
||||
<Text bold={sel === i} color={sel === i ? t.color.warn : t.color.dim} inverse={sel === i}>
|
||||
<Text bold={sel === i} color={sel === i ? t.color.warn : t.color.muted} inverse={sel === i}>
|
||||
{sel === i ? '▸ ' : ' '}
|
||||
{i + 1}. {LABELS[o]}
|
||||
</Text>
|
||||
</Text>
|
||||
))}
|
||||
|
||||
<Text color={t.color.dim}>↑/↓ select · Enter confirm · 1-4 quick pick · Ctrl+C deny</Text>
|
||||
<Text color={t.color.muted}>↑/↓ select · Enter confirm · 1-4 quick pick · Ctrl+C deny</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -84,8 +84,8 @@ export function ClarifyPrompt({ cols = 80, onAnswer, onCancel, req, t }: Clarify
|
||||
|
||||
const heading = (
|
||||
<Text bold>
|
||||
<Text color={t.color.amber}>ask</Text>
|
||||
<Text color={t.color.cornsilk}> {req.question}</Text>
|
||||
<Text color={t.color.accent}>ask</Text>
|
||||
<Text color={t.color.text}> {req.question}</Text>
|
||||
</Text>
|
||||
)
|
||||
|
||||
@@ -129,7 +129,7 @@ export function ClarifyPrompt({ cols = 80, onAnswer, onCancel, req, t }: Clarify
|
||||
<TextInput columns={Math.max(20, cols - 6)} onChange={setCustom} onSubmit={onAnswer} value={custom} />
|
||||
</Box>
|
||||
|
||||
<Text color={t.color.dim}>
|
||||
<Text color={t.color.muted}>
|
||||
Enter send · Esc {choices.length ? 'back' : 'cancel'} ·{' '}
|
||||
{isMac ? 'Cmd+C copy · Cmd+V paste · Ctrl+C cancel' : 'Ctrl+C cancel'}
|
||||
</Text>
|
||||
@@ -143,14 +143,14 @@ 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.dim} inverse={sel === i}>
|
||||
<Text bold={sel === i} color={sel === i ? t.color.label : t.color.muted} inverse={sel === i}>
|
||||
{sel === i ? '▸ ' : ' '}
|
||||
{i + 1}. {c}
|
||||
</Text>
|
||||
</Text>
|
||||
))}
|
||||
|
||||
<Text color={t.color.dim}>↑/↓ select · Enter confirm · 1-{choices.length} quick pick · Esc/Ctrl+C cancel</Text>
|
||||
<Text color={t.color.muted}>↑/↓ select · Enter confirm · 1-{choices.length} quick pick · Esc/Ctrl+C cancel</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -185,8 +185,8 @@ export function ConfirmPrompt({ onCancel, onConfirm, req, t }: ConfirmPromptProp
|
||||
const accent = req.danger ? t.color.error : t.color.warn
|
||||
|
||||
const rows = [
|
||||
{ color: t.color.cornsilk, label: req.cancelLabel ?? 'No' },
|
||||
{ color: req.danger ? t.color.error : t.color.cornsilk, label: req.confirmLabel ?? 'Yes' }
|
||||
{ color: t.color.text, label: req.cancelLabel ?? 'No' },
|
||||
{ color: req.danger ? t.color.error : t.color.text, label: req.confirmLabel ?? 'Yes' }
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -197,7 +197,7 @@ export function ConfirmPrompt({ onCancel, onConfirm, req, t }: ConfirmPromptProp
|
||||
|
||||
{req.detail ? (
|
||||
<Box paddingLeft={1}>
|
||||
<Text color={t.color.cornsilk} wrap="truncate-end">
|
||||
<Text color={t.color.text} wrap="truncate-end">
|
||||
{req.detail}
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -207,12 +207,12 @@ export function ConfirmPrompt({ onCancel, onConfirm, req, t }: ConfirmPromptProp
|
||||
|
||||
{rows.map((row, i) => (
|
||||
<Text key={row.label}>
|
||||
<Text color={sel === i ? accent : t.color.dim}>{sel === i ? '▸ ' : ' '}</Text>
|
||||
<Text color={sel === i ? row.color : t.color.dim}>{row.label}</Text>
|
||||
<Text color={sel === i ? accent : t.color.muted}>{sel === i ? '▸ ' : ' '}</Text>
|
||||
<Text color={sel === i ? row.color : t.color.muted}>{row.label}</Text>
|
||||
</Text>
|
||||
))}
|
||||
|
||||
<Text color={t.color.dim}>↑/↓ select · Enter confirm · Y/N quick · Esc cancel</Text>
|
||||
<Text color={t.color.muted}>↑/↓ select · Enter confirm · Y/N quick · Esc cancel</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,14 +23,14 @@ export function QueuedMessages({ cols, queueEditIdx, queued, t }: QueuedMessages
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text color={t.color.dim} dimColor>
|
||||
<Text color={t.color.muted} dimColor>
|
||||
{`queued (${queued.length})${
|
||||
queueEditIdx !== null ? ` · editing ${queueEditIdx + 1} · Ctrl+X delete · Esc cancel` : ''
|
||||
}`}
|
||||
</Text>
|
||||
|
||||
{q.showLead && (
|
||||
<Text color={t.color.dim} dimColor>
|
||||
<Text color={t.color.muted} dimColor>
|
||||
{' '}
|
||||
…
|
||||
</Text>
|
||||
@@ -41,14 +41,14 @@ export function QueuedMessages({ cols, queueEditIdx, queued, t }: QueuedMessages
|
||||
const active = queueEditIdx === idx
|
||||
|
||||
return (
|
||||
<Text color={active ? t.color.amber : t.color.dim} dimColor key={`${idx}-${item.slice(0, 16)}`}>
|
||||
<Text color={active ? t.color.accent : t.color.muted} dimColor key={`${idx}-${item.slice(0, 16)}`}>
|
||||
{active ? '▸' : ' '} {idx + 1}. {compactPreview(item, Math.max(16, cols - 10))}
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
|
||||
{q.showTail && (
|
||||
<Text color={t.color.dim} dimColor>
|
||||
<Text color={t.color.muted} dimColor>
|
||||
{' '}…and {queued.length - q.end} more
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -80,7 +80,7 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps)
|
||||
})
|
||||
|
||||
if (loading) {
|
||||
return <Text color={t.color.dim}>loading sessions…</Text>
|
||||
return <Text color={t.color.muted}>loading sessions…</Text>
|
||||
}
|
||||
|
||||
if (err) {
|
||||
@@ -95,7 +95,7 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps)
|
||||
if (!items.length) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={t.color.dim}>no previous sessions</Text>
|
||||
<Text color={t.color.muted}>no previous sessions</Text>
|
||||
<OverlayHint t={t}>Esc/q cancel</OverlayHint>
|
||||
</Box>
|
||||
)
|
||||
@@ -105,11 +105,11 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width={width}>
|
||||
<Text bold color={t.color.amber}>
|
||||
<Text bold color={t.color.accent}>
|
||||
Resume Session
|
||||
</Text>
|
||||
|
||||
{offset > 0 && <Text color={t.color.dim}> ↑ {offset} more</Text>}
|
||||
{offset > 0 && <Text color={t.color.muted}> ↑ {offset} more</Text>}
|
||||
|
||||
{items.slice(offset, offset + VISIBLE).map((s, vi) => {
|
||||
const i = offset + vi
|
||||
@@ -117,30 +117,35 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps)
|
||||
|
||||
return (
|
||||
<Box key={s.id}>
|
||||
<Text bold={selected} color={selected ? t.color.amber : t.color.dim} inverse={selected}>
|
||||
<Text bold={selected} color={selected ? t.color.accent : t.color.muted} inverse={selected}>
|
||||
{selected ? '▸ ' : ' '}
|
||||
</Text>
|
||||
|
||||
<Box width={30}>
|
||||
<Text bold={selected} color={selected ? t.color.amber : t.color.dim} inverse={selected}>
|
||||
<Text bold={selected} color={selected ? t.color.accent : t.color.muted} inverse={selected}>
|
||||
{String(i + 1).padStart(2)}. [{s.id}]
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box width={30}>
|
||||
<Text bold={selected} color={selected ? t.color.amber : t.color.dim} inverse={selected}>
|
||||
<Text bold={selected} color={selected ? t.color.accent : t.color.muted} inverse={selected}>
|
||||
({s.message_count} msgs, {age(s.started_at)}, {s.source || 'tui'})
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Text bold={selected} color={selected ? t.color.amber : t.color.dim} inverse={selected} wrap="truncate-end">
|
||||
<Text
|
||||
bold={selected}
|
||||
color={selected ? t.color.accent : t.color.muted}
|
||||
inverse={selected}
|
||||
wrap="truncate-end"
|
||||
>
|
||||
{s.title || s.preview || '(untitled)'}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
|
||||
{offset + VISIBLE < items.length && <Text color={t.color.dim}> ↓ {items.length - offset - VISIBLE} more</Text>}
|
||||
{offset + VISIBLE < items.length && <Text color={t.color.muted}> ↓ {items.length - offset - VISIBLE} more</Text>}
|
||||
<OverlayHint t={t}>↑/↓ select · Enter resume · 1-9 quick · Esc/q cancel</OverlayHint>
|
||||
</Box>
|
||||
)
|
||||
|
||||
@@ -179,7 +179,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
|
||||
})
|
||||
|
||||
if (loading) {
|
||||
return <Text color={t.color.dim}>loading skills…</Text>
|
||||
return <Text color={t.color.muted}>loading skills…</Text>
|
||||
}
|
||||
|
||||
if (err && stage === 'category') {
|
||||
@@ -194,7 +194,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
|
||||
if (!cats.length) {
|
||||
return (
|
||||
<Box flexDirection="column" width={width}>
|
||||
<Text color={t.color.dim}>no skills available</Text>
|
||||
<Text color={t.color.muted}>no skills available</Text>
|
||||
<OverlayHint t={t}>Esc/q cancel</OverlayHint>
|
||||
</Box>
|
||||
)
|
||||
@@ -206,12 +206,12 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width={width}>
|
||||
<Text bold color={t.color.amber}>
|
||||
<Text bold color={t.color.accent}>
|
||||
Skills Hub
|
||||
</Text>
|
||||
|
||||
<Text color={t.color.dim}>select a category</Text>
|
||||
{offset > 0 && <Text color={t.color.dim}> ↑ {offset} more</Text>}
|
||||
<Text color={t.color.muted}>select a category</Text>
|
||||
{offset > 0 && <Text color={t.color.muted}> ↑ {offset} more</Text>}
|
||||
|
||||
{items.map((row, i) => {
|
||||
const idx = offset + i
|
||||
@@ -219,7 +219,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
|
||||
return (
|
||||
<Text
|
||||
bold={catIdx === idx}
|
||||
color={catIdx === idx ? t.color.amber : t.color.dim}
|
||||
color={catIdx === idx ? t.color.accent : t.color.muted}
|
||||
inverse={catIdx === idx}
|
||||
key={row}
|
||||
wrap="truncate-end"
|
||||
@@ -230,7 +230,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
|
||||
)
|
||||
})}
|
||||
|
||||
{offset + VISIBLE < rows.length && <Text color={t.color.dim}> ↓ {rows.length - offset - VISIBLE} more</Text>}
|
||||
{offset + VISIBLE < rows.length && <Text color={t.color.muted}> ↓ {rows.length - offset - VISIBLE} more</Text>}
|
||||
<OverlayHint t={t}>↑/↓ select · Enter open · 1-9,0 quick · Esc/q cancel</OverlayHint>
|
||||
</Box>
|
||||
)
|
||||
@@ -241,13 +241,13 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width={width}>
|
||||
<Text bold color={t.color.amber}>
|
||||
<Text bold color={t.color.accent}>
|
||||
{selectedCat}
|
||||
</Text>
|
||||
|
||||
<Text color={t.color.dim}>{skills.length} skill(s)</Text>
|
||||
{!skills.length ? <Text color={t.color.dim}>no skills in this category</Text> : null}
|
||||
{offset > 0 && <Text color={t.color.dim}> ↑ {offset} more</Text>}
|
||||
<Text color={t.color.muted}>{skills.length} skill(s)</Text>
|
||||
{!skills.length ? <Text color={t.color.muted}>no skills in this category</Text> : null}
|
||||
{offset > 0 && <Text color={t.color.muted}> ↑ {offset} more</Text>}
|
||||
|
||||
{items.map((row, i) => {
|
||||
const idx = offset + i
|
||||
@@ -255,7 +255,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
|
||||
return (
|
||||
<Text
|
||||
bold={skillIdx === idx}
|
||||
color={skillIdx === idx ? t.color.amber : t.color.dim}
|
||||
color={skillIdx === idx ? t.color.accent : t.color.muted}
|
||||
inverse={skillIdx === idx}
|
||||
key={row}
|
||||
wrap="truncate-end"
|
||||
@@ -267,7 +267,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
|
||||
})}
|
||||
|
||||
{offset + VISIBLE < skills.length && (
|
||||
<Text color={t.color.dim}> ↓ {skills.length - offset - VISIBLE} more</Text>
|
||||
<Text color={t.color.muted}> ↓ {skills.length - offset - VISIBLE} more</Text>
|
||||
)}
|
||||
<OverlayHint t={t}>
|
||||
{skills.length ? '↑/↓ select · Enter open · 1-9,0 quick · Esc back · q close' : 'Esc back · q close'}
|
||||
@@ -278,16 +278,16 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width={width}>
|
||||
<Text bold color={t.color.amber}>
|
||||
<Text bold color={t.color.accent}>
|
||||
{info?.name ?? skillName}
|
||||
</Text>
|
||||
|
||||
<Text color={t.color.dim}>{info?.category ?? selectedCat}</Text>
|
||||
{info?.description ? <Text color={t.color.cornsilk}>{info.description}</Text> : null}
|
||||
{info?.path ? <Text color={t.color.dim}>path: {info.path}</Text> : null}
|
||||
{!info && !err ? <Text color={t.color.dim}>loading…</Text> : null}
|
||||
<Text color={t.color.muted}>{info?.category ?? selectedCat}</Text>
|
||||
{info?.description ? <Text color={t.color.text}>{info.description}</Text> : null}
|
||||
{info?.path ? <Text color={t.color.muted}>path: {info.path}</Text> : null}
|
||||
{!info && !err ? <Text color={t.color.muted}>loading…</Text> : null}
|
||||
{err ? <Text color={t.color.label}>error: {err}</Text> : null}
|
||||
{installing ? <Text color={t.color.amber}>installing…</Text> : null}
|
||||
{installing ? <Text color={t.color.accent}>installing…</Text> : null}
|
||||
|
||||
<OverlayHint t={t}>i reinspect · x reinstall · Enter/Esc back · q close</OverlayHint>
|
||||
</Box>
|
||||
|
||||
@@ -360,6 +360,10 @@ 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.
|
||||
const rendered = useMemo(() => {
|
||||
if (!focus) {
|
||||
return display || dim(placeholder)
|
||||
@@ -711,6 +715,14 @@ export function TextInput({
|
||||
if (range && range.start === range.end) {
|
||||
selRef.current = null
|
||||
setSel(null)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const normalized = selRange()
|
||||
|
||||
if (isMac && normalized) {
|
||||
void writeClipboardText(vRef.current.slice(normalized.start, normalized.end))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ function TreeRow({
|
||||
return (
|
||||
<Box>
|
||||
<NoSelect flexShrink={0} fromLeftEdge width={lead.length}>
|
||||
<Text color={stemColor ?? t.color.dim} dim={stemDim}>
|
||||
<Text color={stemColor ?? t.color.muted} dim={stemDim}>
|
||||
{lead}
|
||||
</Text>
|
||||
</NoSelect>
|
||||
@@ -246,12 +246,12 @@ function Chevron({
|
||||
title: string
|
||||
tone?: 'dim' | 'error' | 'warn'
|
||||
}) {
|
||||
const color = tone === 'error' ? t.color.error : tone === 'warn' ? t.color.warn : t.color.dim
|
||||
const color = tone === 'error' ? t.color.error : tone === 'warn' ? t.color.warn : t.color.muted
|
||||
|
||||
return (
|
||||
<Box onClick={(e: any) => onClick(!!e?.shiftKey || !!e?.ctrlKey)}>
|
||||
<Text color={color} dim={tone === 'dim'}>
|
||||
<Text color={t.color.amber}>{open ? '▾ ' : '▸ '}</Text>
|
||||
<Text color={t.color.accent}>{open ? '▾ ' : '▸ '}</Text>
|
||||
{title}
|
||||
{typeof count === 'number' ? ` (${count})` : ''}
|
||||
{suffix ? (
|
||||
@@ -266,7 +266,7 @@ function Chevron({
|
||||
}
|
||||
|
||||
function heatColor(node: SubagentNode, peak: number, theme: Theme): string | undefined {
|
||||
const palette = [theme.color.bronze, theme.color.amber, theme.color.gold, theme.color.warn, theme.color.error]
|
||||
const palette = [theme.color.border, theme.color.accent, theme.color.primary, theme.color.warn, theme.color.error]
|
||||
const idx = hotnessBucket(node.aggregate.hotness, peak, palette.length)
|
||||
|
||||
// Below the median bucket we keep the default dim stem so cool branches
|
||||
@@ -394,7 +394,7 @@ function SubagentAccordion({
|
||||
const hasTools = item.tools.length > 0
|
||||
const noteRows = [...(summary ? [summary] : []), ...item.notes]
|
||||
const hasNotes = noteRows.length > 0
|
||||
const noteColor = statusTone === 'error' ? t.color.error : statusTone === 'warn' ? t.color.warn : t.color.dim
|
||||
const noteColor = statusTone === 'error' ? t.color.error : statusTone === 'warn' ? t.color.warn : t.color.muted
|
||||
|
||||
const sections: {
|
||||
header: ReactNode
|
||||
@@ -460,10 +460,10 @@ function SubagentAccordion({
|
||||
{item.tools.map((line, index) => (
|
||||
<TreeTextRow
|
||||
branch={index === item.tools.length - 1 ? 'last' : 'mid'}
|
||||
color={t.color.cornsilk}
|
||||
color={t.color.text}
|
||||
content={
|
||||
<>
|
||||
<Text color={t.color.amber}>● </Text>
|
||||
<Text color={t.color.accent}>● </Text>
|
||||
{line}
|
||||
</>
|
||||
}
|
||||
@@ -649,22 +649,22 @@ export const Thinking = memo(function Thinking({
|
||||
{preview ? (
|
||||
mode === 'full' ? (
|
||||
lines.map((line, index) => (
|
||||
<Text color={t.color.dim} key={index} wrap="wrap-trim">
|
||||
<Text color={t.color.muted} key={index} wrap="wrap-trim">
|
||||
{line || ' '}
|
||||
{index === lines.length - 1 ? (
|
||||
<StreamCursor color={t.color.dim} streaming={streaming} visible={active} />
|
||||
<StreamCursor color={t.color.muted} streaming={streaming} visible={active} />
|
||||
) : null}
|
||||
</Text>
|
||||
))
|
||||
) : (
|
||||
<Text color={t.color.dim} wrap="truncate-end">
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{preview}
|
||||
<StreamCursor color={t.color.dim} streaming={streaming} visible={active} />
|
||||
<StreamCursor color={t.color.muted} streaming={streaming} visible={active} />
|
||||
</Text>
|
||||
)
|
||||
) : (
|
||||
<Text color={t.color.dim}>
|
||||
<StreamCursor color={t.color.dim} streaming={streaming} visible={active} />
|
||||
<Text color={t.color.muted}>
|
||||
<StreamCursor color={t.color.muted} streaming={streaming} visible={active} />
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
@@ -792,7 +792,7 @@ export const ToolTrail = memo(function ToolTrail({
|
||||
|
||||
if (parsed) {
|
||||
groups.push({
|
||||
color: parsed.mark === '✗' ? t.color.error : t.color.cornsilk,
|
||||
color: parsed.mark === '✗' ? t.color.error : t.color.text,
|
||||
content: parsed.call,
|
||||
details: [],
|
||||
key: `tr-${i}`,
|
||||
@@ -801,7 +801,7 @@ export const ToolTrail = memo(function ToolTrail({
|
||||
|
||||
if (parsed.detail) {
|
||||
pushDetail({
|
||||
color: parsed.mark === '✗' ? t.color.error : t.color.dim,
|
||||
color: parsed.mark === '✗' ? t.color.error : t.color.muted,
|
||||
content: parsed.detail,
|
||||
dimColor: parsed.mark !== '✗',
|
||||
key: `tr-${i}-d`
|
||||
@@ -815,9 +815,9 @@ export const ToolTrail = memo(function ToolTrail({
|
||||
const label = toolTrailLabel(line.slice(9).replace(/…$/, '').trim())
|
||||
|
||||
groups.push({
|
||||
color: t.color.cornsilk,
|
||||
color: t.color.text,
|
||||
content: label,
|
||||
details: [{ color: t.color.dim, content: 'drafting...', dimColor: true, key: `tr-${i}-d` }],
|
||||
details: [{ color: t.color.muted, content: 'drafting...', dimColor: true, key: `tr-${i}-d` }],
|
||||
key: `tr-${i}`,
|
||||
label
|
||||
})
|
||||
@@ -827,12 +827,12 @@ export const ToolTrail = memo(function ToolTrail({
|
||||
|
||||
if (line === 'analyzing tool output…') {
|
||||
pushDetail({
|
||||
color: t.color.dim,
|
||||
color: t.color.muted,
|
||||
dimColor: true,
|
||||
key: `tr-${i}`,
|
||||
content: groups.length ? (
|
||||
<>
|
||||
<Spinner color={t.color.amber} variant="think" /> {line}
|
||||
<Spinner color={t.color.accent} variant="think" /> {line}
|
||||
</>
|
||||
) : (
|
||||
line
|
||||
@@ -842,20 +842,20 @@ export const ToolTrail = memo(function ToolTrail({
|
||||
continue
|
||||
}
|
||||
|
||||
meta.push({ color: t.color.dim, content: line, dimColor: true, key: `tr-${i}` })
|
||||
meta.push({ color: t.color.muted, content: line, dimColor: true, key: `tr-${i}` })
|
||||
}
|
||||
|
||||
for (const tool of tools) {
|
||||
const label = formatToolCall(tool.name, tool.context || '')
|
||||
|
||||
groups.push({
|
||||
color: t.color.cornsilk,
|
||||
color: t.color.text,
|
||||
key: tool.id,
|
||||
label,
|
||||
details: [],
|
||||
content: (
|
||||
<>
|
||||
<Spinner color={t.color.amber} variant="tool" /> {label}
|
||||
<Spinner color={t.color.accent} variant="tool" /> {label}
|
||||
{tool.startedAt ? ` (${fmtElapsed(now - tool.startedAt)})` : ''}
|
||||
</>
|
||||
)
|
||||
@@ -864,7 +864,7 @@ export const ToolTrail = memo(function ToolTrail({
|
||||
|
||||
for (const item of activity.slice(-4)) {
|
||||
const glyph = item.tone === 'error' ? '✗' : item.tone === 'warn' ? '!' : '·'
|
||||
const color = item.tone === 'error' ? t.color.error : item.tone === 'warn' ? t.color.warn : t.color.dim
|
||||
const color = item.tone === 'error' ? t.color.error : item.tone === 'warn' ? t.color.warn : t.color.muted
|
||||
meta.push({ color, content: `${glyph} ${item.text}`, dimColor: item.tone === 'info', key: `a-${item.id}` })
|
||||
}
|
||||
|
||||
@@ -873,7 +873,7 @@ export const ToolTrail = memo(function ToolTrail({
|
||||
const hasTools = groups.length > 0
|
||||
const hasSubagents = subagents.length > 0
|
||||
const hasMeta = meta.length > 0
|
||||
const hasThinking = !!cot || reasoningActive || busy
|
||||
const hasThinking = !!cot || reasoningActive || reasoningStreaming
|
||||
const thinkingLive = reasoningActive || reasoningStreaming
|
||||
|
||||
const tokenCount =
|
||||
@@ -998,14 +998,14 @@ export const ToolTrail = memo(function ToolTrail({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text color={t.color.dim} dim={!thinkingLive}>
|
||||
<Text color={t.color.amber}>{openThinking ? '▾ ' : '▸ '}</Text>
|
||||
<Text color={t.color.muted} dim={!thinkingLive}>
|
||||
<Text color={t.color.accent}>{openThinking ? '▾ ' : '▸ '}</Text>
|
||||
{thinkingLive ? (
|
||||
<Text bold color={t.color.cornsilk}>
|
||||
<Text bold color={t.color.text}>
|
||||
Thinking
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={t.color.dim} dim>
|
||||
<Text color={t.color.muted} dim>
|
||||
Thinking
|
||||
</Text>
|
||||
)}
|
||||
@@ -1068,7 +1068,7 @@ export const ToolTrail = memo(function ToolTrail({
|
||||
color={group.color}
|
||||
content={
|
||||
<>
|
||||
<Text color={t.color.amber}>● </Text>
|
||||
<Text color={t.color.accent}>● </Text>
|
||||
{toolLabel(group)}
|
||||
</>
|
||||
}
|
||||
@@ -1182,7 +1182,7 @@ export const ToolTrail = memo(function ToolTrail({
|
||||
color={t.color.statusFg}
|
||||
content={
|
||||
<>
|
||||
<Text color={t.color.amber}>Σ </Text>
|
||||
<Text color={t.color.accent}>Σ </Text>
|
||||
{totalTokensLabel}
|
||||
</>
|
||||
}
|
||||
@@ -1192,7 +1192,7 @@ export const ToolTrail = memo(function ToolTrail({
|
||||
) : null}
|
||||
{outcome ? (
|
||||
<Box marginTop={1}>
|
||||
<Text color={t.color.dim} dim>
|
||||
<Text color={t.color.muted} dim>
|
||||
· {outcome}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { TodoItem } from '../types.js'
|
||||
const rowColor = (t: Theme, status: TodoItem['status']) => {
|
||||
const tone = todoTone(status)
|
||||
|
||||
return tone === 'active' ? t.color.cornsilk : tone === 'body' ? t.color.statusFg : t.color.dim
|
||||
return tone === 'active' ? t.color.text : tone === 'body' ? t.color.statusFg : t.color.muted
|
||||
}
|
||||
|
||||
export const TodoPanel = memo(function TodoPanel({
|
||||
@@ -56,16 +56,16 @@ export const TodoPanel = memo(function TodoPanel({
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Box onClick={handleToggle}>
|
||||
<Text color={t.color.dim}>
|
||||
<Text color={t.color.amber}>{effectiveCollapsed ? '▸ ' : '▾ '}</Text>
|
||||
<Text bold color={t.color.cornsilk}>
|
||||
<Text color={t.color.muted}>
|
||||
<Text color={t.color.accent}>{effectiveCollapsed ? '▸ ' : '▾ '}</Text>
|
||||
<Text bold color={t.color.text}>
|
||||
Todo
|
||||
</Text>{' '}
|
||||
<Text color={t.color.statusFg} dim>
|
||||
({done}/{todos.length})
|
||||
</Text>
|
||||
{incomplete && pending > 0 && (
|
||||
<Text color={t.color.dim} dim>
|
||||
<Text color={t.color.muted} dim>
|
||||
{' '}
|
||||
· incomplete · {pending} still {pending === 1 ? 'pending' : 'pending/in_progress'}
|
||||
</Text>
|
||||
|
||||
@@ -2,8 +2,8 @@ import type { Theme } from '../theme.js'
|
||||
import type { Role } from '../types.js'
|
||||
|
||||
export const ROLE: Record<Role, (t: Theme) => { body: string; glyph: string; prefix: string }> = {
|
||||
assistant: t => ({ body: t.color.cornsilk, glyph: t.brand.tool, prefix: t.color.bronze }),
|
||||
system: t => ({ body: '', glyph: '·', prefix: t.color.dim }),
|
||||
tool: t => ({ body: t.color.dim, glyph: '⚡', prefix: t.color.dim }),
|
||||
assistant: t => ({ body: t.color.text, glyph: t.brand.tool, prefix: t.color.border }),
|
||||
system: t => ({ body: '', glyph: '·', prefix: t.color.muted }),
|
||||
tool: t => ({ body: t.color.muted, glyph: '⚡', prefix: t.color.muted }),
|
||||
user: t => ({ body: t.color.label, glyph: t.brand.prompt, prefix: t.color.label })
|
||||
}
|
||||
|
||||
@@ -26,21 +26,25 @@ export const stickyPromptFromViewport = (
|
||||
return ''
|
||||
}
|
||||
|
||||
const first = Math.max(0, Math.min(messages.length - 1, upperBound(offsets, top) - 1))
|
||||
const last = Math.max(first, Math.min(messages.length - 1, upperBound(offsets, bottom) - 1))
|
||||
const first = Math.max(0, upperBound(offsets, top) - 1)
|
||||
const last = Math.max(first, upperBound(offsets, bottom) - 1)
|
||||
const visibleStart = Math.min(messages.length, first)
|
||||
const visibleEnd = Math.min(messages.length - 1, last)
|
||||
|
||||
for (let i = first; i <= last; i++) {
|
||||
for (let i = visibleStart; i <= visibleEnd; i++) {
|
||||
if (messages[i]?.role === 'user') {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = first - 1; i >= 0; i--) {
|
||||
for (let i = Math.min(messages.length - 1, visibleStart - 1); i >= 0; i--) {
|
||||
if (messages[i]?.role !== 'user') {
|
||||
continue
|
||||
}
|
||||
|
||||
return (offsets[i] ?? 0) + 1 < top ? userDisplay(messages[i]!.text.trim()).replace(/\s+/g, ' ').trim() : ''
|
||||
return (offsets[i + 1] ?? (offsets[i] ?? 0) + 1) <= top
|
||||
? userDisplay(messages[i]!.text.trim()).replace(/\s+/g, ' ').trim()
|
||||
: ''
|
||||
}
|
||||
|
||||
return ''
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
#!/usr/bin/env -S node --max-old-space-size=8192 --expose-gc
|
||||
// Must be first import — mutates process.env.FORCE_COLOR / COLORTERM before
|
||||
// any chalk / supports-color import so the banner gradient renders in
|
||||
// truecolor instead of being downsampled to 256-color (which collapses
|
||||
// gold #FFD700 and amber #FFBF00 to the same slot).
|
||||
import './lib/forceTruecolor.js'
|
||||
|
||||
import type { FrameEvent } from '@hermes/ink'
|
||||
|
||||
import { GatewayClient } from './gatewayClient.js'
|
||||
|
||||
@@ -56,6 +56,7 @@ export interface ConfigDisplayConfig {
|
||||
busy_input_mode?: string
|
||||
details_mode?: string
|
||||
inline_diffs?: boolean
|
||||
mouse_tracking?: boolean | null | number | string
|
||||
sections?: Record<string, string>
|
||||
show_cost?: boolean
|
||||
show_reasoning?: boolean
|
||||
@@ -63,7 +64,14 @@ export interface ConfigDisplayConfig {
|
||||
thinking_mode?: string
|
||||
tui_auto_resume_recent?: boolean
|
||||
tui_compact?: boolean
|
||||
tui_mouse?: boolean
|
||||
/** Legacy alias for display.mouse_tracking. */
|
||||
tui_mouse?: boolean | null | number | string
|
||||
// Forward-compat: backend may send styles this client doesn't know yet —
|
||||
// `normalizeIndicatorStyle` falls back to 'kaomoji' for those — but the
|
||||
// wire type is documented as `string` so consumers don't get a false
|
||||
// narrowing-and-autocomplete contract on a value that requires runtime
|
||||
// validation anyway.
|
||||
tui_status_indicator?: string
|
||||
tui_statusbar?: 'bottom' | 'off' | 'on' | 'top' | boolean
|
||||
}
|
||||
|
||||
@@ -424,7 +432,11 @@ export type GatewayEvent =
|
||||
| { payload?: { state?: 'idle' | 'listening' | 'transcribing' }; session_id?: string; type: 'voice.status' }
|
||||
| { payload?: { no_speech_limit?: boolean; text?: string }; session_id?: string; type: 'voice.transcript' }
|
||||
| { payload: { line: string }; session_id?: string; type: 'gateway.stderr' }
|
||||
| { payload?: { cwd?: string; python?: string; stderr_tail?: string }; session_id?: string; type: 'gateway.start_timeout' }
|
||||
| {
|
||||
payload?: { cwd?: string; python?: string; stderr_tail?: string }
|
||||
session_id?: string
|
||||
type: 'gateway.start_timeout'
|
||||
}
|
||||
| { payload?: { preview?: string }; session_id?: string; type: 'gateway.protocol_error' }
|
||||
| { payload?: { text?: string }; session_id?: string; type: 'reasoning.delta' | 'reasoning.available' }
|
||||
| { payload: { name?: string; preview?: string }; session_id?: string; type: 'tool.progress' }
|
||||
|
||||
32
ui-tui/src/lib/forceTruecolor.ts
Normal file
32
ui-tui/src/lib/forceTruecolor.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Force 24-bit truecolor output before any chalk / supports-color import.
|
||||
*
|
||||
* Why this exists:
|
||||
* The base CLI (Python/Rich) emits banner colors as truecolor ANSI
|
||||
* (`\033[38;2;R;G;Bm`). The TUI renders through Ink → chalk, whose
|
||||
* supports-color auto-detection defaults to 256-color on macOS Terminal.app
|
||||
* and any terminal that does NOT set `COLORTERM=truecolor`. In 256-color
|
||||
* mode, chalk downsamples `#FFD700` (gold) and `#FFBF00` (amber) to the
|
||||
* *same* xterm-256 palette slot (220) — collapsing the banner gradient
|
||||
* into a single flat yellow band. The bronze and dim rows also lose
|
||||
* contrast against each other.
|
||||
*
|
||||
* Terminal.app (macOS 12+), iTerm2, kitty, Alacritty, VS Code, Cursor,
|
||||
* and WezTerm all render truecolor correctly. The few that don't
|
||||
* (ancient xterm, some CI environments) can set `HERMES_TUI_TRUECOLOR=0`
|
||||
* to opt out.
|
||||
*
|
||||
* This MUST run before any `chalk` or `supports-color` import. supports-color
|
||||
* caches its level on first load, so nudging env vars after that point has
|
||||
* no effect.
|
||||
*/
|
||||
|
||||
if (process.env.HERMES_TUI_TRUECOLOR !== '0' && !process.env.NO_COLOR && !process.env.FORCE_COLOR) {
|
||||
if (!process.env.COLORTERM) {
|
||||
process.env.COLORTERM = 'truecolor'
|
||||
}
|
||||
|
||||
process.env.FORCE_COLOR = '3'
|
||||
}
|
||||
|
||||
export {}
|
||||
@@ -42,7 +42,13 @@ export const isCopyShortcut = (
|
||||
ch: string,
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): boolean =>
|
||||
isAction(key, ch, 'c') || (isRemoteShell(env) && (key.meta || key.super === true) && ch.toLowerCase() === 'c')
|
||||
ch.toLowerCase() === 'c' &&
|
||||
(isAction(key, ch, 'c') ||
|
||||
(isRemoteShell(env) && (key.meta || key.super === true)) ||
|
||||
// VS Code/Cursor/Windsurf terminal setup forwards Cmd+C as a CSI-u
|
||||
// sequence with the super bit plus a benign ctrl bit. Accept that shape
|
||||
// even though raw Ctrl+C should remain interrupt on local macOS.
|
||||
(isMac && key.ctrl && (key.meta || key.super === true)))
|
||||
|
||||
/**
|
||||
* Voice recording toggle key (Ctrl+B).
|
||||
|
||||
@@ -80,7 +80,7 @@ export function highlightLine(line: string, lang: string, t: Theme): Token[] {
|
||||
}
|
||||
|
||||
if (spec.comment && line.trimStart().startsWith(spec.comment)) {
|
||||
return [[t.color.dim, line]]
|
||||
return [[t.color.muted, line]]
|
||||
}
|
||||
|
||||
const tokens: Token[] = []
|
||||
@@ -97,11 +97,11 @@ export function highlightLine(line: string, lang: string, t: Theme): Token[] {
|
||||
const ch = tok[0]!
|
||||
|
||||
if (ch === '"' || ch === "'" || ch === '`') {
|
||||
tokens.push([t.color.amber, tok])
|
||||
tokens.push([t.color.accent, tok])
|
||||
} else if (ch >= '0' && ch <= '9') {
|
||||
tokens.push([t.color.cornsilk, tok])
|
||||
tokens.push([t.color.text, tok])
|
||||
} else if (spec.keywords.has(tok)) {
|
||||
tokens.push([t.color.bronze, tok])
|
||||
tokens.push([t.color.border, tok])
|
||||
} else {
|
||||
tokens.push(['', tok])
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ export type TerminalSetupResult = {
|
||||
}
|
||||
|
||||
const DEFAULT_FILE_OPS: FileOps = { copyFile, mkdir, readFile, writeFile }
|
||||
const COPY_SEQUENCE = '\u001b[99;13u'
|
||||
const MULTILINE_SEQUENCE = '\\\r\n'
|
||||
|
||||
const TERMINAL_META: Record<SupportedTerminal, { appName: string; label: string }> = {
|
||||
@@ -33,7 +34,14 @@ const TERMINAL_META: Record<SupportedTerminal, { appName: string; label: string
|
||||
windsurf: { appName: 'Windsurf', label: 'Windsurf' }
|
||||
}
|
||||
|
||||
const TARGET_BINDINGS: Keybinding[] = [
|
||||
const MAC_COPY_BINDING: Keybinding = {
|
||||
key: 'cmd+c',
|
||||
command: 'workbench.action.terminal.sendSequence',
|
||||
when: 'terminalFocus && terminalTextSelected',
|
||||
args: { text: COPY_SEQUENCE }
|
||||
}
|
||||
|
||||
const BASE_BINDINGS: Keybinding[] = [
|
||||
{
|
||||
key: 'shift+enter',
|
||||
command: 'workbench.action.terminal.sendSequence',
|
||||
@@ -66,6 +74,9 @@ const TARGET_BINDINGS: Keybinding[] = [
|
||||
}
|
||||
]
|
||||
|
||||
const targetBindings = (platform: NodeJS.Platform): Keybinding[] =>
|
||||
platform === 'darwin' ? [MAC_COPY_BINDING, ...BASE_BINDINGS] : BASE_BINDINGS
|
||||
|
||||
export function detectVSCodeLikeTerminal(env: NodeJS.ProcessEnv = process.env): null | SupportedTerminal {
|
||||
const askpass = env['VSCODE_GIT_ASKPASS_MAIN']?.toLowerCase() ?? ''
|
||||
|
||||
@@ -172,6 +183,90 @@ function sameBinding(a: Keybinding, b: Keybinding): boolean {
|
||||
return a.key === b.key && a.command === b.command && a.when === b.when && a.args?.text === b.args?.text
|
||||
}
|
||||
|
||||
type WhenRequirements = {
|
||||
forbidden: Set<string>
|
||||
required: Set<string>
|
||||
}
|
||||
|
||||
const WHEN_TOKEN_RE = /!?[A-Za-z_][\w.]*/g
|
||||
|
||||
function parseWhenRequirements(when: string): WhenRequirements {
|
||||
const required = new Set<string>()
|
||||
const forbidden = new Set<string>()
|
||||
|
||||
for (const [token] of when.matchAll(WHEN_TOKEN_RE)) {
|
||||
if (token.startsWith('!')) {
|
||||
forbidden.add(token.slice(1))
|
||||
} else {
|
||||
required.add(token)
|
||||
}
|
||||
}
|
||||
|
||||
return { forbidden, required }
|
||||
}
|
||||
|
||||
function requirementsContradict(a: WhenRequirements, b: WhenRequirements): boolean {
|
||||
for (const token of a.required) {
|
||||
if (b.forbidden.has(token)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
for (const token of b.required) {
|
||||
if (a.forbidden.has(token)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function whensOverlap(a: string, b: string): boolean {
|
||||
if (a === b) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Empty when = global, overlaps every context.
|
||||
if (!a || !b) {
|
||||
return true
|
||||
}
|
||||
|
||||
const left = parseWhenRequirements(a)
|
||||
const right = parseWhenRequirements(b)
|
||||
|
||||
if (requirementsContradict(left, right)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// This intentionally avoids a full VS Code when-clause parser. If two
|
||||
// same-key bindings share a positive context token and don't explicitly
|
||||
// contradict each other, they can fire together in that context.
|
||||
for (const token of left.required) {
|
||||
if (right.required.has(token)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// VS Code allows multiple bindings on the same key as long as their `when`
|
||||
// clauses don't overlap. We flag a conflict when the contexts overlap but
|
||||
// the bindings differ — e.g. existing `terminalFocus` cmd+c overlaps with
|
||||
// our `terminalFocus && terminalTextSelected`, so the existing binding
|
||||
// would shadow ours when text isn't selected.
|
||||
function bindingsConflict(existing: Keybinding, target: Keybinding): boolean {
|
||||
if (existing.key !== target.key) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!whensOverlap(existing.when ?? '', target.when ?? '')) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !sameBinding(existing, target)
|
||||
}
|
||||
|
||||
async function backupFile(filePath: string, ops: FileOps): Promise<void> {
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
await ops.copyFile(filePath, `${filePath}.backup.${stamp}`)
|
||||
@@ -240,10 +335,10 @@ export async function configureTerminalKeybindings(
|
||||
}
|
||||
}
|
||||
|
||||
const conflicts = TARGET_BINDINGS.filter(target =>
|
||||
keybindings.some(
|
||||
existing => isKeybinding(existing) && existing.key === target.key && !sameBinding(existing, target)
|
||||
)
|
||||
const targets = targetBindings(platform)
|
||||
|
||||
const conflicts = targets.filter(target =>
|
||||
keybindings.some(existing => isKeybinding(existing) && bindingsConflict(existing, target))
|
||||
)
|
||||
|
||||
if (conflicts.length) {
|
||||
@@ -256,7 +351,7 @@ export async function configureTerminalKeybindings(
|
||||
|
||||
let added = 0
|
||||
|
||||
for (const target of TARGET_BINDINGS.slice().reverse()) {
|
||||
for (const target of targets.slice().reverse()) {
|
||||
const exists = keybindings.some(existing => isKeybinding(existing) && sameBinding(existing, target))
|
||||
|
||||
if (!exists) {
|
||||
@@ -340,7 +435,7 @@ export async function shouldPromptForTerminalSetup(options?: {
|
||||
return true
|
||||
}
|
||||
|
||||
return TARGET_BINDINGS.some(
|
||||
return targetBindings(platform).some(
|
||||
target => !parsed.some(existing => isKeybinding(existing) && sameBinding(existing, target))
|
||||
)
|
||||
} catch {
|
||||
|
||||
@@ -28,11 +28,18 @@ export function getViewportSnapshot(s?: ScrollBoxHandle | null): ViewportSnapsho
|
||||
const pending = s.getPendingDelta()
|
||||
const top = Math.max(0, s.getScrollTop() + pending)
|
||||
const viewportHeight = Math.max(0, s.getViewportHeight())
|
||||
const scrollHeight = Math.max(viewportHeight, s.getScrollHeight())
|
||||
const cachedScrollHeight = Math.max(viewportHeight, s.getScrollHeight())
|
||||
let scrollHeight = cachedScrollHeight
|
||||
const bottom = top + viewportHeight
|
||||
let atBottom = s.isSticky() || bottom >= scrollHeight - 2
|
||||
|
||||
if (!atBottom) {
|
||||
scrollHeight = Math.max(viewportHeight, s.getFreshScrollHeight?.() ?? cachedScrollHeight)
|
||||
atBottom = s.isSticky() || bottom >= scrollHeight - 2
|
||||
}
|
||||
|
||||
return {
|
||||
atBottom: s.isSticky() || bottom >= scrollHeight - 2,
|
||||
atBottom,
|
||||
bottom,
|
||||
pending,
|
||||
scrollHeight,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
export interface ThemeColors {
|
||||
gold: string
|
||||
amber: string
|
||||
bronze: string
|
||||
cornsilk: string
|
||||
dim: string
|
||||
primary: string
|
||||
accent: string
|
||||
border: string
|
||||
text: string
|
||||
muted: string
|
||||
completionBg: string
|
||||
completionCurrentBg: string
|
||||
|
||||
@@ -88,18 +88,26 @@ const BRAND: ThemeBrand = {
|
||||
helpHeader: '(^_^)? Commands'
|
||||
}
|
||||
|
||||
const cleanPromptSymbol = (s: string | undefined, fallback: string) => {
|
||||
const cleaned = String(s ?? '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
|
||||
return cleaned || fallback
|
||||
}
|
||||
|
||||
export const DARK_THEME: Theme = {
|
||||
color: {
|
||||
gold: '#FFD700',
|
||||
amber: '#FFBF00',
|
||||
bronze: '#CD7F32',
|
||||
cornsilk: '#FFF8DC',
|
||||
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.
|
||||
dim: '#CC9B1F',
|
||||
completionBg: '#FFFFFF',
|
||||
completionCurrentBg: mix('#FFFFFF', '#FFBF00', 0.25),
|
||||
|
||||
@@ -141,11 +149,11 @@ export const DARK_THEME: Theme = {
|
||||
// cleanly (#11300).
|
||||
export const LIGHT_THEME: Theme = {
|
||||
color: {
|
||||
gold: '#8B6914',
|
||||
amber: '#A0651C',
|
||||
bronze: '#7A4F1F',
|
||||
cornsilk: '#3D2F13',
|
||||
dim: '#7A5A0F',
|
||||
primary: '#8B6914',
|
||||
accent: '#A0651C',
|
||||
border: '#7A4F1F',
|
||||
text: '#3D2F13',
|
||||
muted: '#7A5A0F',
|
||||
completionBg: '#F5F5F5',
|
||||
completionCurrentBg: mix('#F5F5F5', '#A0651C', 0.25),
|
||||
|
||||
@@ -179,23 +187,130 @@ export const LIGHT_THEME: Theme = {
|
||||
bannerHero: ''
|
||||
}
|
||||
|
||||
// Pick light vs dark. Explicit `HERMES_TUI_LIGHT` wins; otherwise sniff
|
||||
// `COLORFGBG` (set by XFCE Terminal, rxvt, Terminal.app, etc.) — last field is the
|
||||
// background ANSI index; 7/15 are the "white" slots most light themes emit (#11300).
|
||||
export function detectLightMode(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
const explicit = (env.HERMES_TUI_LIGHT ?? '').trim().toLowerCase()
|
||||
const TRUE_RE = /^(?:1|true|yes|on)$/
|
||||
const FALSE_RE = /^(?:0|false|no|off)$/
|
||||
|
||||
if (/^(?:1|true|yes|on)$/.test(explicit)) {
|
||||
// Reserved for future TERM_PROGRAM-based heuristics. Empty by default:
|
||||
// most modern terminals (Ghostty, Warp, iTerm2, Apple_Terminal) ship a
|
||||
// dark profile out of the box, so guessing wrong here is more annoying
|
||||
// than missing a light user — light users can always set
|
||||
// `HERMES_TUI_LIGHT=1` or `HERMES_TUI_THEME=light`.
|
||||
const LIGHT_DEFAULT_TERM_PROGRAMS = new Set<string>()
|
||||
|
||||
// Best-effort RGB → luminance check. Currently only accepts a 3- or
|
||||
// 6-digit hex value (with or without a leading `#`); the env var name
|
||||
// `HERMES_TUI_BACKGROUND` is intentionally generic so a future OSC11
|
||||
// query helper can cache its answer there too, but additional formats
|
||||
// (rgb()/hsl()/named colours) would need explicit parsing here first.
|
||||
const LUMA_LIGHT_THRESHOLD = 0.6
|
||||
|
||||
// Strict allow-list: parseInt(..., 16) silently truncates at the first
|
||||
// non-hex character (e.g. `fffgff` would parse as `fff` and yield a
|
||||
// false-positive "white" reading), so reject anything that doesn't match
|
||||
// the canonical 3- or 6-digit shape up front.
|
||||
const HEX_3_RE = /^[0-9a-f]{3}$/
|
||||
const HEX_6_RE = /^[0-9a-f]{6}$/
|
||||
|
||||
function backgroundLuminance(raw: string): null | number {
|
||||
const v = raw.trim().toLowerCase()
|
||||
|
||||
if (!v) {
|
||||
return null
|
||||
}
|
||||
|
||||
const hex = v.startsWith('#') ? v.slice(1) : v
|
||||
|
||||
const rgb = HEX_6_RE.test(hex)
|
||||
? [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16)]
|
||||
: HEX_3_RE.test(hex)
|
||||
? [parseInt(hex[0]! + hex[0]!, 16), parseInt(hex[1]! + hex[1]!, 16), parseInt(hex[2]! + hex[2]!, 16)]
|
||||
: null
|
||||
|
||||
if (!rgb) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Rec. 709 luma — close enough for "is this background bright".
|
||||
return (0.2126 * rgb[0]! + 0.7152 * rgb[1]! + 0.0722 * rgb[2]!) / 255
|
||||
}
|
||||
|
||||
// Pick light vs dark with ordered, explainable signals (#11300):
|
||||
//
|
||||
// 1. `HERMES_TUI_LIGHT` boolean — `1`/`true`/`yes`/`on` → light;
|
||||
// `0`/`false`/`no`/`off` → dark. Either explicit value wins
|
||||
// regardless of any later signal.
|
||||
// 2. `HERMES_TUI_THEME` named override — `light` / `dark` win over
|
||||
// every signal below.
|
||||
// 3. `HERMES_TUI_BACKGROUND` hex hint (3- or 6-digit) — luminance
|
||||
// ≥ LUMA_LIGHT_THRESHOLD → light.
|
||||
// 4. `COLORFGBG` last field — XFCE / rxvt / Terminal.app emit
|
||||
// slot 7 or 15 on light profiles; 0–15 ranges are otherwise
|
||||
// treated as authoritatively dark so the TERM_PROGRAM
|
||||
// allow-list below cannot override an explicit dark profile.
|
||||
// 5. `TERM_PROGRAM` light-default allow-list (currently empty).
|
||||
//
|
||||
// Anything we can't decide stays dark — the default Hermes palette
|
||||
// is the dark one.
|
||||
export function detectLightMode(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
// Injectable so tests can prove the COLORFGBG-over-TERM_PROGRAM
|
||||
// precedence rule even though the production allow-list is empty.
|
||||
lightDefaultTermPrograms: ReadonlySet<string> = LIGHT_DEFAULT_TERM_PROGRAMS
|
||||
): boolean {
|
||||
const lightFlag = (env.HERMES_TUI_LIGHT ?? '').trim().toLowerCase()
|
||||
|
||||
if (TRUE_RE.test(lightFlag)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (/^(?:0|false|no|off)$/.test(explicit)) {
|
||||
if (FALSE_RE.test(lightFlag)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const bg = Number((env.COLORFGBG ?? '').trim().split(';').at(-1))
|
||||
const themeFlag = (env.HERMES_TUI_THEME ?? '').trim().toLowerCase()
|
||||
|
||||
return bg === 7 || bg === 15
|
||||
if (themeFlag === 'light') {
|
||||
return true
|
||||
}
|
||||
|
||||
if (themeFlag === 'dark') {
|
||||
return false
|
||||
}
|
||||
|
||||
const bgHint = backgroundLuminance(env.HERMES_TUI_BACKGROUND ?? '')
|
||||
|
||||
if (bgHint !== null) {
|
||||
return bgHint >= LUMA_LIGHT_THRESHOLD
|
||||
}
|
||||
|
||||
const colorfgbg = (env.COLORFGBG ?? '').trim()
|
||||
|
||||
if (colorfgbg) {
|
||||
// Validate as a decimal integer before coercing — `Number('')` is 0,
|
||||
// so a malformed `COLORFGBG='15;'` would otherwise look like an
|
||||
// authoritative dark slot and incorrectly block the TERM_PROGRAM
|
||||
// allow-list. Anything that isn't pure digits falls through.
|
||||
const lastField = colorfgbg.split(';').at(-1) ?? ''
|
||||
|
||||
if (/^\d+$/.test(lastField)) {
|
||||
const bg = Number(lastField)
|
||||
|
||||
if (bg === 7 || bg === 15) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Slots 0–6 and 8–14 are the dark half of the 0–15 ANSI range.
|
||||
// When COLORFGBG is set we trust it as authoritative — a non-light
|
||||
// value here shouldn't get overridden by the TERM_PROGRAM allow-list.
|
||||
if (bg >= 0 && bg < 16) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const termProgram = (env.TERM_PROGRAM ?? '').trim()
|
||||
|
||||
return lightDefaultTermPrograms.has(termProgram)
|
||||
}
|
||||
|
||||
export const DEFAULT_THEME: Theme = detectLightMode() ? LIGHT_THEME : DARK_THEME
|
||||
@@ -213,19 +328,20 @@ export function fromSkin(
|
||||
const d = DEFAULT_THEME
|
||||
const c = (k: string) => colors[k]
|
||||
|
||||
const amber = c('ui_accent') ?? c('banner_accent') ?? d.color.amber
|
||||
const accent = c('banner_accent') ?? c('banner_title') ?? d.color.amber
|
||||
const dim = c('banner_dim') ?? d.color.dim
|
||||
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
|
||||
|
||||
return {
|
||||
color: {
|
||||
gold: c('banner_title') ?? d.color.gold,
|
||||
amber,
|
||||
bronze: c('banner_border') ?? d.color.bronze,
|
||||
cornsilk: c('banner_text') ?? d.color.cornsilk,
|
||||
dim,
|
||||
completionBg: c('completion_menu_bg') ?? '#FFFFFF',
|
||||
completionCurrentBg: c('completion_menu_current_bg') ?? mix('#FFFFFF', accent, 0.25),
|
||||
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: c('completion_menu_current_bg') ?? mix(completionBg, bannerAccent, 0.25),
|
||||
|
||||
label: c('ui_label') ?? d.color.label,
|
||||
ok: c('ui_ok') ?? d.color.ok,
|
||||
@@ -233,8 +349,8 @@ export function fromSkin(
|
||||
warn: c('ui_warn') ?? d.color.warn,
|
||||
|
||||
prompt: c('prompt') ?? c('banner_text') ?? d.color.prompt,
|
||||
sessionLabel: c('session_label') ?? dim,
|
||||
sessionBorder: c('session_border') ?? dim,
|
||||
sessionLabel: c('session_label') ?? muted,
|
||||
sessionBorder: c('session_border') ?? muted,
|
||||
|
||||
statusBg: d.color.statusBg,
|
||||
statusFg: d.color.statusFg,
|
||||
@@ -254,7 +370,7 @@ export function fromSkin(
|
||||
brand: {
|
||||
name: branding.agent_name ?? d.brand.name,
|
||||
icon: d.brand.icon,
|
||||
prompt: branding.prompt_symbol ?? d.brand.prompt,
|
||||
prompt: cleanPromptSymbol(branding.prompt_symbol, d.brand.prompt),
|
||||
welcome: branding.welcome ?? d.brand.welcome,
|
||||
goodbye: branding.goodbye ?? d.brand.goodbye,
|
||||
tool: toolPrefix || d.brand.tool,
|
||||
|
||||
2
ui-tui/src/types/hermes-ink.d.ts
vendored
2
ui-tui/src/types/hermes-ink.d.ts
vendored
@@ -83,6 +83,7 @@ declare module '@hermes/ink' {
|
||||
readonly getScrollTop: () => number
|
||||
readonly getPendingDelta: () => number
|
||||
readonly getScrollHeight: () => number
|
||||
readonly getFreshScrollHeight: () => number
|
||||
readonly getViewportHeight: () => number
|
||||
readonly getViewportTop: () => number
|
||||
readonly getLastManualScrollAt: () => number
|
||||
@@ -145,6 +146,7 @@ declare module '@hermes/ink' {
|
||||
readonly clearSelection: () => void
|
||||
readonly hasSelection: () => boolean
|
||||
readonly getState: () => unknown
|
||||
readonly version: () => number
|
||||
readonly subscribe: (cb: () => void) => () => void
|
||||
readonly shiftAnchor: (dRow: number, minRow: number, maxRow: number) => void
|
||||
readonly shiftSelection: (dRow: number, minRow: number, maxRow: number) => void
|
||||
|
||||
@@ -182,6 +182,161 @@ async def handle(event_type: str, context: dict):
|
||||
}, timeout=5)
|
||||
```
|
||||
|
||||
### Tutorial: BOOT.md — Run a Startup Checklist on Every Gateway Boot
|
||||
|
||||
A popular pattern from the community: drop a Markdown checklist at `~/.hermes/BOOT.md`, and have the agent run it once every time the gateway starts. Useful for "on every boot, check overnight cron failures and ping me on Discord if anything failed," or "summarize the last 24h of deploy.log and post it to Slack #ops."
|
||||
|
||||
This tutorial shows how to build it yourself as a user-defined hook. Hermes does not ship a built-in BOOT.md hook — you wire up exactly the behavior you want.
|
||||
|
||||
#### What we're building
|
||||
|
||||
1. A file at `~/.hermes/BOOT.md` with natural-language startup instructions.
|
||||
2. A gateway hook that fires on `gateway:startup`, spawns a one-shot agent with your gateway's resolved model/credentials, and runs the BOOT.md instructions.
|
||||
3. A `[SILENT]` convention so the agent can opt out of sending a message when there's nothing to report.
|
||||
|
||||
#### Step 1: Write your checklist
|
||||
|
||||
Create `~/.hermes/BOOT.md`. Write it as if you were giving instructions to a human assistant:
|
||||
|
||||
```markdown
|
||||
# Startup Checklist
|
||||
|
||||
1. Run `hermes cron list` and check if any scheduled jobs failed overnight.
|
||||
2. If any failed, send a summary to Discord #ops using the `send_message` tool.
|
||||
3. Check if `/opt/app/deploy.log` has any ERROR lines from the last 24 hours. If yes, summarize them and include in the same Discord message.
|
||||
4. If nothing went wrong, reply with only `[SILENT]` so no message is sent.
|
||||
```
|
||||
|
||||
The agent sees this as part of its prompt, so anything you can describe in plain language works — tool calls, shell commands, sending messages, summarizing files.
|
||||
|
||||
#### Step 2: Create the hook
|
||||
|
||||
```text
|
||||
~/.hermes/hooks/boot-md/
|
||||
├── HOOK.yaml
|
||||
└── handler.py
|
||||
```
|
||||
|
||||
**`~/.hermes/hooks/boot-md/HOOK.yaml`**
|
||||
|
||||
```yaml
|
||||
name: boot-md
|
||||
description: Run ~/.hermes/BOOT.md on gateway startup
|
||||
events:
|
||||
- gateway:startup
|
||||
```
|
||||
|
||||
**`~/.hermes/hooks/boot-md/handler.py`**
|
||||
|
||||
```python
|
||||
"""Run ~/.hermes/BOOT.md on every gateway startup."""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger("hooks.boot-md")
|
||||
|
||||
BOOT_FILE = Path.home() / ".hermes" / "BOOT.md"
|
||||
|
||||
|
||||
def _build_prompt(content: str) -> str:
|
||||
return (
|
||||
"You are running a startup boot checklist. Follow the instructions "
|
||||
"below exactly.\n\n"
|
||||
"---\n"
|
||||
f"{content}\n"
|
||||
"---\n\n"
|
||||
"Execute each instruction. Use the send_message tool to deliver any "
|
||||
"messages to platforms like Discord or Slack.\n"
|
||||
"If nothing needs attention and there is nothing to report, reply "
|
||||
"with ONLY: [SILENT]"
|
||||
)
|
||||
|
||||
|
||||
def _run_boot_agent(content: str) -> None:
|
||||
"""Spawn a one-shot agent and execute the checklist.
|
||||
|
||||
Uses the gateway's resolved model and runtime credentials so this works
|
||||
against custom endpoints, aggregators, and OAuth-based providers alike.
|
||||
"""
|
||||
try:
|
||||
from gateway.run import _resolve_gateway_model, _resolve_runtime_agent_kwargs
|
||||
from run_agent import AIAgent
|
||||
|
||||
agent = AIAgent(
|
||||
model=_resolve_gateway_model(),
|
||||
**_resolve_runtime_agent_kwargs(),
|
||||
platform="gateway",
|
||||
quiet_mode=True,
|
||||
skip_context_files=True,
|
||||
skip_memory=True,
|
||||
max_iterations=20,
|
||||
)
|
||||
result = agent.run_conversation(_build_prompt(content))
|
||||
response = result.get("final_response", "")
|
||||
if response and "[SILENT]" not in response:
|
||||
logger.info("boot-md completed: %s", response[:200])
|
||||
else:
|
||||
logger.info("boot-md completed (nothing to report)")
|
||||
except Exception as e:
|
||||
logger.error("boot-md agent failed: %s", e)
|
||||
|
||||
|
||||
async def handle(event_type: str, context: dict) -> None:
|
||||
if not BOOT_FILE.exists():
|
||||
return
|
||||
content = BOOT_FILE.read_text(encoding="utf-8").strip()
|
||||
if not content:
|
||||
return
|
||||
|
||||
logger.info("Running BOOT.md (%d chars)", len(content))
|
||||
|
||||
# Background thread so gateway startup isn't blocked on a full agent turn.
|
||||
thread = threading.Thread(
|
||||
target=_run_boot_agent,
|
||||
args=(content,),
|
||||
name="boot-md",
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
```
|
||||
|
||||
The two key lines:
|
||||
|
||||
- `_resolve_gateway_model()` reads the gateway's currently-configured model.
|
||||
- `_resolve_runtime_agent_kwargs()` resolves provider credentials the same way a normal gateway turn does — including API keys, base URLs, OAuth tokens, and credential pools.
|
||||
|
||||
Without these, a bare `AIAgent()` falls back to built-in defaults and will 401 against any non-default endpoint.
|
||||
|
||||
#### Step 3: Test it
|
||||
|
||||
Restart the gateway:
|
||||
|
||||
```bash
|
||||
hermes gateway restart
|
||||
```
|
||||
|
||||
Watch the logs:
|
||||
|
||||
```bash
|
||||
hermes logs --follow --level INFO | grep boot-md
|
||||
```
|
||||
|
||||
You should see `Running BOOT.md (N chars)` followed by either `boot-md completed: ...` (summary of what the agent did) or `boot-md completed (nothing to report)` when the agent replied `[SILENT]`.
|
||||
|
||||
Delete `~/.hermes/BOOT.md` to disable the checklist — the hook stays loaded but silently skips when the file isn't there.
|
||||
|
||||
#### Extending the pattern
|
||||
|
||||
- **Schedule-aware checklists:** key off `datetime.now().weekday()` inside BOOT.md's instructions ("if it's Monday, also check the weekly deploy log"). The instructions are free-form text, so anything the agent can reason about is fair game.
|
||||
- **Multiple checklists:** point the hook at a different file (`STARTUP.md`, `MORNING.md`, etc.) and register separate hook directories for each.
|
||||
- **Non-agent variant:** if you don't need a full agent loop, skip `AIAgent` entirely and have the handler post a fixed notification directly via `httpx`. Cheaper, faster, and has no provider dependency.
|
||||
|
||||
#### Why this isn't a built-in
|
||||
|
||||
An earlier version of Hermes shipped this as a built-in hook and silently spawned an agent with bare defaults on every gateway boot. That surprised users with custom endpoints and made the feature invisible to users who didn't know it was running. Keeping it as a documented pattern — built by you, in your hooks directory — means you see exactly what it does and opt in by writing the files.
|
||||
|
||||
### How It Works
|
||||
|
||||
1. On gateway startup, `HookRegistry.discover_and_load()` scans `~/.hermes/hooks/`
|
||||
|
||||
@@ -41,6 +41,7 @@ display:
|
||||
| `poseidon` | Ocean-god theme — deep blue and seafoam | `Poseidon Agent` | Deep blue to seafoam gradient. Ocean-themed spinners ("charting currents", "sounding the depth"). Trident ASCII art banner. |
|
||||
| `sisyphus` | Sisyphean theme — austere grayscale with persistence | `Sisyphus Agent` | Light grays with stark contrast. Boulder-themed spinners ("pushing uphill", "resetting the boulder", "enduring the loop"). Boulder-and-hill ASCII art banner. |
|
||||
| `charizard` | Volcanic theme — burnt orange and ember | `Charizard Agent` | Warm burnt orange to ember gradient. Fire-themed spinners ("banking into the draft", "measuring burn"). Dragon-silhouette ASCII art banner. |
|
||||
| `bunnny` | Barbie-pink coquette theme — sparkles, bows, and bubblegum | `Hermes Agent` | Hot pink (`#E91E63`) borders with Barbie-pink (`#FF69B4`) accents and lavender-blush text. Coquette spinner verbs ("sparkling", "twirling", "tying a little bow"). Heart (♡) prompt symbol, sparkle-kaomoji greeting, twin-bunny hero art. |
|
||||
|
||||
## Complete list of configurable keys
|
||||
|
||||
@@ -95,7 +96,7 @@ Text strings used throughout the CLI interface.
|
||||
| `welcome` | Welcome message shown at CLI startup | `Welcome to Hermes Agent! Type your message or /help for commands.` |
|
||||
| `goodbye` | Message shown on exit | `Goodbye! ⚕` |
|
||||
| `response_label` | Label on the response box header | ` ⚕ Hermes ` |
|
||||
| `prompt_symbol` | Symbol before the user input prompt | `❯ ` |
|
||||
| `prompt_symbol` | Symbol before the user input prompt (bare token, renderers add a trailing space) | `❯` |
|
||||
| `help_header` | Header text for the `/help` command output | `(^_^)? Available Commands` |
|
||||
|
||||
### Other top-level keys
|
||||
@@ -167,7 +168,7 @@ branding:
|
||||
welcome: "Welcome to My Agent! Type your message or /help for commands."
|
||||
goodbye: "See you later! ⚡"
|
||||
response_label: " ⚡ My Agent "
|
||||
prompt_symbol: "⚡ ❯ "
|
||||
prompt_symbol: "⚡"
|
||||
help_header: "(⚡) Available Commands"
|
||||
|
||||
tool_prefix: "┊"
|
||||
|
||||
Reference in New Issue
Block a user