Merge remote-tracking branch 'origin/main' into fix/markdown

Made-with: Cursor

# Conflicts:
#	ui-tui/src/components/markdown.tsx
This commit is contained in:
Austin Pickett 2026-04-28 22:01:02 -04:00
commit e4120d1e6d
82 changed files with 3565 additions and 491 deletions

View file

@ -5,7 +5,9 @@
# Dependencies # Dependencies
node_modules node_modules
**/node_modules
.venv .venv
**/.venv
# CI/CD # CI/CD
.github .github

View file

@ -494,7 +494,7 @@ branding:
agent_name: "My Agent" agent_name: "My Agent"
welcome: "Welcome message" welcome: "Welcome message"
response_label: " ⚔ Agent " response_label: " ⚔ Agent "
prompt_symbol: "⚔ " prompt_symbol: "⚔"
tool_prefix: "╎" # Tool output line prefix tool_prefix: "╎" # Tool output line prefix
``` ```

View file

@ -14,7 +14,7 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright
# that would otherwise accumulate when hermes runs as PID 1. See #15012. # that would otherwise accumulate when hermes runs as PID 1. See #15012.
RUN apt-get update && \ RUN apt-get update && \
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
build-essential nodejs npm python3 ripgrep ffmpeg gcc python3-dev libffi-dev procps git openssh-client docker-cli tini && \ build-essential nodejs npm python3 ripgrep ffmpeg gcc python3-dev libffi-dev procps git openssh-client docker-cli tini && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
# Non-root user for runtime; UID can be overridden via HERMES_UID at runtime # Non-root user for runtime; UID can be overridden via HERMES_UID at runtime
@ -45,7 +45,13 @@ COPY --chown=hermes:hermes . .
# Build browser dashboard and terminal UI assets. # Build browser dashboard and terminal UI assets.
RUN cd web && npm run build && \ RUN cd web && npm run build && \
cd ../ui-tui && npm run build cd ../ui-tui && npm run build && \
rm -rf node_modules/@hermes/ink && \
rm -rf packages/hermes-ink/node_modules && \
cp -R packages/hermes-ink node_modules/@hermes/ink && \
npm install --omit=dev --prefer-offline --no-audit --prefix node_modules/@hermes/ink && \
rm -rf node_modules/@hermes/ink/node_modules/react && \
node --input-type=module -e "await import('@hermes/ink')"
# ---------- Permissions ---------- # ---------- Permissions ----------
# Make install dir world-readable so any HERMES_UID can read it at runtime. # Make install dir world-readable so any HERMES_UID can read it at runtime.

View file

@ -927,7 +927,7 @@ display:
# agent_name: "My Agent" # Banner title and branding # agent_name: "My Agent" # Banner title and branding
# welcome: "Welcome message" # Shown at CLI startup # welcome: "Welcome message" # Shown at CLI startup
# response_label: " ⚔ Agent " # Response box header label # 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: ┊) # tool_prefix: "╎" # Tool output line prefix (default: ┊)
# #
skin: default skin: default

View file

@ -128,6 +128,9 @@ COMMAND_REGISTRY: list[CommandDef] = [
subcommands=("normal", "fast", "status", "on", "off")), subcommands=("normal", "fast", "status", "on", "off")),
CommandDef("skin", "Show or change the display skin/theme", "Configuration", CommandDef("skin", "Show or change the display skin/theme", "Configuration",
cli_only=True, args_hint="[name]"), 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", CommandDef("voice", "Toggle voice mode", "Configuration",
args_hint="[on|off|tts|status]", subcommands=("on", "off", "tts", "status")), args_hint="[on|off|tts|status]", subcommands=("on", "off", "tts", "status")),
CommandDef("busy", "Control what Enter does while Hermes is working", "Configuration", CommandDef("busy", "Control what Enter does while Hermes is working", "Configuration",

View file

@ -703,6 +703,11 @@ DEFAULT_CONFIG = {
"personality": "kawaii", "personality": "kawaii",
"resume_display": "full", "resume_display": "full",
"busy_input_mode": "interrupt", # interrupt | queue | steer "busy_input_mode": "interrupt", # interrupt | queue | steer
# When true, `hermes --tui` auto-resumes the most recent human-
# facing session on launch instead of forging a fresh one.
# Mirrors `hermes -c` muscle memory. Default off so existing
# users aren't surprised. HERMES_TUI_RESUME=<id> always wins.
"tui_auto_resume_recent": False,
"bell_on_complete": False, "bell_on_complete": False,
"show_reasoning": False, "show_reasoning": False,
"streaming": False, "streaming": False,
@ -710,6 +715,9 @@ DEFAULT_CONFIG = {
"inline_diffs": True, # Show inline diff previews for write actions (write_file, patch, skill_manage) "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) "show_cost": False, # Show $ cost in the status bar (off by default)
"skin": "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 "user_message_preview": { # CLI: how many submitted user-message lines to echo back in scrollback
"first_lines": 2, "first_lines": 2,
"last_lines": 2, "last_lines": 2,

View file

@ -293,15 +293,23 @@ def run_doctor(args):
known_providers: set = set() known_providers: set = set()
try: 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"} known_providers = set(PROVIDER_REGISTRY.keys()) | {"openrouter", "custom", "auto"}
except Exception: except Exception:
_resolve_auth_provider = None
pass pass
try: try:
from hermes_cli.config import get_compatible_custom_providers as _compatible_custom_providers 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: except Exception:
_compatible_custom_providers = None _compatible_custom_providers = None
_normalize_catalog_provider = None
_resolve_provider_full = None _resolve_provider_full = None
custom_providers = [] custom_providers = []
@ -321,17 +329,43 @@ def run_doctor(args):
if name: if name:
known_providers.add("custom:" + name.lower().replace(" ", "-")) 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 ( if (
provider provider
and _resolve_provider_full is not None and _resolve_provider_full is not None
and provider not in ("auto", "custom") and provider not in ("auto", "custom")
): ):
provider_def = _resolve_provider_full(provider, user_providers, custom_providers) 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 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)" known_list = ", ".join(sorted(known_providers)) if known_providers else "(unavailable)"
check_fail( check_fail(
f"model.provider '{provider_raw}' is not a recognised provider", 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 # 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( check_warn(
f"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'", f"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'",
"(vendor-prefixed slugs belong to aggregators like openrouter)", "(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() # own env-var checks elsewhere in doctor, and get_auth_status()
# returns a bare {logged_in: False} for anything it doesn't # returns a bare {logged_in: False} for anything it doesn't
# explicitly dispatch, which would produce false positives. # 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: try:
from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status 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": if pconfig and getattr(pconfig, "auth_type", "") == "api_key":
status = get_auth_status(canonical_provider) or {} status = get_auth_status(runtime_provider) or {}
configured = bool(status.get("configured") or status.get("logged_in") or status.get("api_key")) configured = bool(
status.get("configured")
or status.get("logged_in")
or status.get("api_key")
)
if not configured: if not configured:
check_fail( 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')", "(check ~/.hermes/.env or run 'hermes setup')",
) )
issues.append( 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"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, "
f"or switch providers with 'hermes config set model.provider <name>'" f"or switch providers with 'hermes config set model.provider <name>'"
) )

View file

@ -68,7 +68,7 @@ All fields are optional. Missing values inherit from the ``default`` skin.
welcome: "Welcome message" # Shown at CLI startup welcome: "Welcome message" # Shown at CLI startup
goodbye: "Goodbye! ⚕" # Shown on exit goodbye: "Goodbye! ⚕" # Shown on exit
response_label: " ⚕ Hermes " # Response box header label 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 help_header: "(^_^)? Commands" # /help header text
# Tool prefix: character for tool output lines (default: ┊) # Tool prefix: character for tool output lines (default: ┊)
@ -190,7 +190,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
"welcome": "Welcome to Hermes Agent! Type your message or /help for commands.", "welcome": "Welcome to Hermes Agent! Type your message or /help for commands.",
"goodbye": "Goodbye! ⚕", "goodbye": "Goodbye! ⚕",
"response_label": " ⚕ Hermes ", "response_label": " ⚕ Hermes ",
"prompt_symbol": " ", "prompt_symbol": "",
"help_header": "(^_^)? Available Commands", "help_header": "(^_^)? Available Commands",
}, },
"tool_prefix": "", "tool_prefix": "",
@ -242,7 +242,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
"welcome": "Welcome to Ares Agent! Type your message or /help for commands.", "welcome": "Welcome to Ares Agent! Type your message or /help for commands.",
"goodbye": "Farewell, warrior! ⚔", "goodbye": "Farewell, warrior! ⚔",
"response_label": " ⚔ Ares ", "response_label": " ⚔ Ares ",
"prompt_symbol": " ", "prompt_symbol": "",
"help_header": "(⚔) Available Commands", "help_header": "(⚔) Available Commands",
}, },
"tool_prefix": "", "tool_prefix": "",
@ -301,7 +301,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
"welcome": "Welcome to Hermes Agent! Type your message or /help for commands.", "welcome": "Welcome to Hermes Agent! Type your message or /help for commands.",
"goodbye": "Goodbye! ⚕", "goodbye": "Goodbye! ⚕",
"response_label": " ⚕ Hermes ", "response_label": " ⚕ Hermes ",
"prompt_symbol": " ", "prompt_symbol": "",
"help_header": "[?] Available Commands", "help_header": "[?] Available Commands",
}, },
"tool_prefix": "", "tool_prefix": "",
@ -340,7 +340,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
"welcome": "Welcome to Hermes Agent! Type your message or /help for commands.", "welcome": "Welcome to Hermes Agent! Type your message or /help for commands.",
"goodbye": "Goodbye! ⚕", "goodbye": "Goodbye! ⚕",
"response_label": " ⚕ Hermes ", "response_label": " ⚕ Hermes ",
"prompt_symbol": " ", "prompt_symbol": "",
"help_header": "(^_^)? Available Commands", "help_header": "(^_^)? Available Commands",
}, },
"tool_prefix": "", "tool_prefix": "",
@ -377,7 +377,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
"welcome": "Welcome to Hermes Agent! Type your message or /help for commands.", "welcome": "Welcome to Hermes Agent! Type your message or /help for commands.",
"goodbye": "Goodbye! ⚕", "goodbye": "Goodbye! ⚕",
"response_label": " ⚕ Hermes ", "response_label": " ⚕ Hermes ",
"prompt_symbol": " ", "prompt_symbol": "",
"help_header": "[?] Available Commands", "help_header": "[?] Available Commands",
}, },
"tool_prefix": "", "tool_prefix": "",
@ -414,7 +414,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
"welcome": "Welcome to Hermes Agent! Type your message or /help for commands.", "welcome": "Welcome to Hermes Agent! Type your message or /help for commands.",
"goodbye": "Goodbye! \u2695", "goodbye": "Goodbye! \u2695",
"response_label": " \u2695 Hermes ", "response_label": " \u2695 Hermes ",
"prompt_symbol": "\u276f ", "prompt_symbol": "\u276f",
"help_header": "(^_^)? Available Commands", "help_header": "(^_^)? Available Commands",
}, },
"tool_prefix": "\u250a", "tool_prefix": "\u250a",
@ -467,7 +467,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
"welcome": "Welcome to Poseidon Agent! Type your message or /help for commands.", "welcome": "Welcome to Poseidon Agent! Type your message or /help for commands.",
"goodbye": "Fair winds! Ψ", "goodbye": "Fair winds! Ψ",
"response_label": " Ψ Poseidon ", "response_label": " Ψ Poseidon ",
"prompt_symbol": "Ψ ", "prompt_symbol": "Ψ",
"help_header": "(Ψ) Available Commands", "help_header": "(Ψ) Available Commands",
}, },
"tool_prefix": "", "tool_prefix": "",
@ -539,7 +539,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
"welcome": "Welcome to Sisyphus Agent! Type your message or /help for commands.", "welcome": "Welcome to Sisyphus Agent! Type your message or /help for commands.",
"goodbye": "The boulder waits. ◉", "goodbye": "The boulder waits. ◉",
"response_label": " ◉ Sisyphus ", "response_label": " ◉ Sisyphus ",
"prompt_symbol": " ", "prompt_symbol": "",
"help_header": "(◉) Available Commands", "help_header": "(◉) Available Commands",
}, },
"tool_prefix": "", "tool_prefix": "",
@ -612,7 +612,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
"welcome": "Welcome to Charizard Agent! Type your message or /help for commands.", "welcome": "Welcome to Charizard Agent! Type your message or /help for commands.",
"goodbye": "Flame out! ✦", "goodbye": "Flame out! ✦",
"response_label": " ✦ Charizard ", "response_label": " ✦ Charizard ",
"prompt_symbol": " ", "prompt_symbol": "",
"help_header": "(✦) Available Commands", "help_header": "(✦) Available Commands",
}, },
"tool_prefix": "", "tool_prefix": "",
@ -780,12 +780,21 @@ def init_skin_from_config(config: dict) -> None:
# ============================================================================= # =============================================================================
def get_active_prompt_symbol(fallback: str = " ") -> str: def get_active_prompt_symbol(fallback: str = "") -> str:
"""Get the interactive prompt symbol from the active skin.""" """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: try:
return get_active_skin().get_branding("prompt_symbol", fallback) raw = get_active_skin().get_branding("prompt_symbol", fallback)
except Exception: except Exception:
return fallback raw = fallback
cleaned = (raw or fallback).strip()
return f"{cleaned or fallback.strip()} "

View file

@ -206,6 +206,27 @@ _LEGACY_TOOLSET_MAP = {
# get_tool_definitions (the main schema provider) # 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( def get_tool_definitions(
enabled_toolsets: List[str] = None, enabled_toolsets: List[str] = None,
disabled_toolsets: List[str] = None, disabled_toolsets: List[str] = None,
@ -224,6 +245,50 @@ def get_tool_definitions(
Returns: Returns:
Filtered list of OpenAI-format tool definitions. 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 # Determine which tool names the caller wants
tools_to_include: set = set() tools_to_include: set = set()

View file

@ -165,6 +165,17 @@
NEW_HASH=$(echo "$OUTPUT" | awk '/got:/ {print $2; exit}') NEW_HASH=$(echo "$OUTPUT" | awk '/got:/ {print $2; exit}')
if [ -z "$NEW_HASH" ]; then 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 " build failed with no hash mismatch:" >&2
echo "$OUTPUT" | tail -40 >&2 echo "$OUTPUT" | tail -40 >&2
exit 1 exit 1

View file

@ -4,7 +4,7 @@ let
src = ../web; src = ../web;
npmDeps = pkgs.fetchNpmDeps { npmDeps = pkgs.fetchNpmDeps {
inherit src; inherit src;
hash = "sha256-AahWmJ9gDQ9pMPa1FYwUjYdO2mOi6JM9Mst27E0vp68="; hash = "sha256-+B2+Fe4djPzHHcUXRx+m0cuyaopAhW0PcHsMgYfV5VE=";
}; };
npm = hermesNpmLib.mkNpmPassthru { folder = "web"; attr = "web"; pname = "hermes-web"; }; npm = hermesNpmLib.mkNpmPassthru { folder = "web"; attr = "web"; pname = "hermes-web"; };

View file

@ -84,6 +84,7 @@ AUTHOR_MAP = {
"6548898+romanornr@users.noreply.github.com": "romanornr", "6548898+romanornr@users.noreply.github.com": "romanornr",
"foxion37@gmail.com": "foxion37", "foxion37@gmail.com": "foxion37",
"bloodcarter@gmail.com": "bloodcarter", "bloodcarter@gmail.com": "bloodcarter",
"scott@scotttrinh.com": "scotttrinh",
# contributors (from noreply pattern) # contributors (from noreply pattern)
"david.vv@icloud.com": "davidvv", "david.vv@icloud.com": "davidvv",
"wangqiang@wangqiangdeMac-mini.local": "xiaoqiang243", "wangqiang@wangqiangdeMac-mini.local": "xiaoqiang243",

View file

@ -40,14 +40,14 @@ class TestCliSkinPromptIntegration:
cli = _make_cli_stub() cli = _make_cli_stub()
set_active_skin("ares") 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): def test_secret_prompt_fragments_preserve_secret_state(self):
cli = _make_cli_stub() cli = _make_cli_stub()
cli._secret_state = {"response_queue": object()} cli._secret_state = {"response_queue": object()}
set_active_skin("ares") 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): def test_narrow_terminals_compact_voice_prompt_fragments(self):
cli = _make_cli_stub() cli = _make_cli_stub()

View file

@ -480,3 +480,29 @@ def _enforce_test_timeout():
yield yield
signal.alarm(0) signal.alarm(0)
signal.signal(signal.SIGALRM, old) 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

View file

@ -130,7 +130,7 @@ class TestCmdUpdateBranchFallback:
# 3. web/ — install + "npm run build" for the web frontend # 3. web/ — install + "npm run build" for the web frontend
full_flags = [ full_flags = [
"/usr/bin/npm", "/usr/bin/npm",
"install", "ci",
"--silent", "--silent",
"--no-fund", "--no-fund",
"--no-audit", "--no-audit",
@ -139,7 +139,7 @@ class TestCmdUpdateBranchFallback:
assert npm_calls == [ assert npm_calls == [
(full_flags, PROJECT_ROOT), (full_flags, PROJECT_ROOT),
(full_flags, PROJECT_ROOT / "ui-tui"), (full_flags, PROJECT_ROOT / "ui-tui"),
(["/usr/bin/npm", "install", "--silent"], PROJECT_ROOT / "web"), (["/usr/bin/npm", "ci", "--silent"], PROJECT_ROOT / "web"),
(["/usr/bin/npm", "run", "build"], PROJECT_ROOT / "web"), (["/usr/bin/npm", "run", "build"], PROJECT_ROOT / "web"),
] ]

View file

@ -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 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): def test_run_doctor_termux_does_not_mark_browser_available_without_agent_browser(monkeypatch, tmp_path):
home = tmp_path / ".hermes" home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True) home.mkdir(parents=True, exist_ok=True)

View file

@ -252,7 +252,7 @@ class TestCliBrandingHelpers:
from hermes_cli.skin_engine import set_active_skin, get_active_prompt_symbol from hermes_cli.skin_engine import set_active_skin, get_active_prompt_symbol
set_active_skin("ares") set_active_skin("ares")
assert get_active_prompt_symbol() == " " assert get_active_prompt_symbol() == " "
def test_active_help_header_ares(self): def test_active_help_header_ares(self):
from hermes_cli.skin_engine import set_active_skin, get_active_help_header from hermes_cli.skin_engine import set_active_skin, get_active_help_header

View file

@ -40,14 +40,14 @@ class TestCliSkinPromptIntegration:
cli = _make_cli_stub() cli = _make_cli_stub()
set_active_skin("ares") 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): def test_secret_prompt_fragments_preserve_secret_state(self):
cli = _make_cli_stub() cli = _make_cli_stub()
cli._secret_state = {"response_queue": object()} cli._secret_state = {"response_queue": object()}
set_active_skin("ares") 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): def test_icon_only_skin_symbol_still_visible_in_special_states(self):
cli = _make_cli_stub() cli = _make_cli_stub()

View file

@ -944,6 +944,39 @@ def test_config_set_section_rejects_unknown_section_or_mode(tmp_path, monkeypatc
assert bad_mode["error"]["code"] == 4002 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): def test_enable_gateway_prompts_sets_gateway_env(monkeypatch):
monkeypatch.delenv("HERMES_EXEC_ASK", raising=False) monkeypatch.delenv("HERMES_EXEC_ASK", raising=False)
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False) monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
@ -980,6 +1013,14 @@ def test_complete_slash_includes_tui_details_command():
assert any(item["text"] == "/details" for item in resp["result"]["items"]) assert any(item["text"] == "/details" for item in resp["result"]["items"])
def test_complete_slash_includes_tui_mouse_command():
resp = server.handle_request(
{"id": "1", "method": "complete.slash", "params": {"text": "/mou"}}
)
assert any(item["text"] == "/mouse" for item in resp["result"]["items"])
def test_complete_slash_details_args(): def test_complete_slash_details_args():
resp_root = server.handle_request( resp_root = server.handle_request(
{"id": "0", "method": "complete.slash", "params": {"text": "/details"}} {"id": "0", "method": "complete.slash", "params": {"text": "/details"}}
@ -1547,6 +1588,19 @@ def test_commands_catalog_surfaces_quick_commands(monkeypatch):
assert resp["result"]["canon"]["/notes"] == "/notes" assert resp["result"]["canon"]["/notes"] == "/notes"
def test_commands_catalog_includes_tui_mouse_command():
resp = server.handle_request(
{"id": "1", "method": "commands.catalog", "params": {}}
)
pairs = dict(resp["result"]["pairs"])
tui_cat = next(c for c in resp["result"]["categories"] if c["name"] == "TUI")
tui_pairs = dict(tui_cat["pairs"])
assert "/mouse" in pairs
assert "/mouse" in tui_pairs
def test_command_dispatch_exec_nonzero_surfaces_error(monkeypatch): def test_command_dispatch_exec_nonzero_surfaces_error(monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
server, server,
@ -2633,3 +2687,452 @@ def test_prompt_submit_skips_auto_title_when_response_empty(monkeypatch):
) )
mock_title.assert_not_called() mock_title.assert_not_called()
# ── session.most_recent ──────────────────────────────────────────────
def test_session_most_recent_returns_first_non_denied(monkeypatch):
"""Drops `tool` rows like session.list does, returns the first hit."""
class _DB:
def list_sessions_rich(self, *, source=None, limit=200):
return [
{"id": "tool-1", "source": "tool", "title": "noise", "started_at": 100},
{"id": "tui-1", "source": "tui", "title": "real", "started_at": 99},
]
monkeypatch.setattr(server, "_get_db", lambda: _DB())
resp = server.handle_request(
{"id": "1", "method": "session.most_recent", "params": {}}
)
assert resp["result"]["session_id"] == "tui-1"
assert resp["result"]["title"] == "real"
assert resp["result"]["source"] == "tui"
def test_session_most_recent_returns_null_when_only_tool_rows(monkeypatch):
class _DB:
def list_sessions_rich(self, *, source=None, limit=200):
return [{"id": "tool-1", "source": "tool", "started_at": 1}]
monkeypatch.setattr(server, "_get_db", lambda: _DB())
resp = server.handle_request(
{"id": "1", "method": "session.most_recent", "params": {}}
)
assert resp["result"]["session_id"] is None
def test_session_most_recent_folds_db_exception_into_null_result(monkeypatch):
"""Per contract, errors are folded into the null-result shape so
callers don't have to special-case JSON-RPC error envelopes for
'no answer' (Copilot review on #17130)."""
class _BrokenDB:
def list_sessions_rich(self, *, source=None, limit=200):
raise RuntimeError("db locked")
monkeypatch.setattr(server, "_get_db", lambda: _BrokenDB())
resp = server.handle_request(
{"id": "1", "method": "session.most_recent", "params": {}}
)
assert "error" not in resp
assert resp["result"]["session_id"] is None
def test_session_most_recent_handles_db_unavailable(monkeypatch):
monkeypatch.setattr(server, "_get_db", lambda: None)
resp = server.handle_request(
{"id": "1", "method": "session.most_recent", "params": {}}
)
assert resp["result"]["session_id"] is None
# ── browser.manage ───────────────────────────────────────────────────
def _stub_urlopen(monkeypatch, *, ok: bool):
"""Patch urllib.request.urlopen used by browser.manage to short-circuit probes."""
class _Resp:
status = 200 if ok else 503
def __enter__(self):
return self
def __exit__(self, *_):
return False
def _opener(_url, timeout=2.0): # noqa: ARG001 — match urllib signature
if not ok:
raise OSError("probe failed")
return _Resp()
import urllib.request
monkeypatch.setattr(urllib.request, "urlopen", _opener)
def test_browser_manage_status_reads_env_var(monkeypatch):
"""Status returns the env var verbatim (no network I/O)."""
monkeypatch.setenv("BROWSER_CDP_URL", "http://127.0.0.1:9222")
resp = server.handle_request(
{"id": "1", "method": "browser.manage", "params": {"action": "status"}}
)
assert resp["result"] == {"connected": True, "url": "http://127.0.0.1:9222"}
def test_browser_manage_status_falls_back_to_config_cdp_url(monkeypatch):
"""When env is unset, status surfaces ``browser.cdp_url`` from
config.yaml so users see what the next tool call will read."""
monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
fake_cfg = types.SimpleNamespace(
read_raw_config=lambda: {"browser": {"cdp_url": "http://lan:9222"}}
)
with patch.dict(sys.modules, {"hermes_cli.config": fake_cfg}):
resp = server.handle_request(
{"id": "1", "method": "browser.manage", "params": {"action": "status"}}
)
assert resp["result"] == {"connected": True, "url": "http://lan:9222"}
def test_browser_manage_status_does_not_call_get_cdp_override(monkeypatch):
"""Regression guard for Copilot's "status must not block" review:
status must NOT route through `_get_cdp_override`, which performs a
`/json/version` HTTP probe with a multi-second timeout."""
monkeypatch.setenv("BROWSER_CDP_URL", "http://127.0.0.1:9222")
fake = types.SimpleNamespace(
_get_cdp_override=lambda: pytest.fail( # noqa: PT015 — fail loudly if called
"_get_cdp_override must not run on /browser status (network I/O)"
)
)
with patch.dict(sys.modules, {"tools.browser_tool": fake}):
resp = server.handle_request(
{"id": "1", "method": "browser.manage", "params": {"action": "status"}}
)
assert resp["result"]["connected"] is True
def test_browser_manage_connect_sets_env_and_cleans_twice(monkeypatch):
"""`/browser connect` must reach the live process: set env, reap browser
sessions before AND after publishing the new URL. The double-cleanup
closes the supervisor swap window where ``_ensure_cdp_supervisor``
could re-attach to the *old* CDP endpoint between steps."""
monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
cleanup_calls: list[str] = []
def _cleanup_all():
cleanup_calls.append(os.environ.get("BROWSER_CDP_URL", ""))
fake = types.SimpleNamespace(
cleanup_all_browsers=_cleanup_all,
_get_cdp_override=lambda: os.environ.get("BROWSER_CDP_URL", ""),
)
with patch.dict(sys.modules, {"tools.browser_tool": fake}):
_stub_urlopen(monkeypatch, ok=True)
resp = server.handle_request(
{
"id": "1",
"method": "browser.manage",
"params": {"action": "connect", "url": "http://127.0.0.1:9222"},
}
)
assert resp["result"] == {"connected": True, "url": "http://127.0.0.1:9222"}
assert os.environ.get("BROWSER_CDP_URL") == "http://127.0.0.1:9222"
# First cleanup runs against the OLD env (none here), second against the NEW.
assert cleanup_calls == ["", "http://127.0.0.1:9222"]
def test_browser_manage_connect_rejects_unreachable_endpoint(monkeypatch):
"""An unreachable endpoint must NOT mutate the env or reap sessions."""
monkeypatch.setenv("BROWSER_CDP_URL", "http://existing:9222")
cleanup_calls: list[str] = []
fake = types.SimpleNamespace(
cleanup_all_browsers=lambda: cleanup_calls.append(os.environ.get("BROWSER_CDP_URL", "")),
_get_cdp_override=lambda: os.environ.get("BROWSER_CDP_URL", ""),
)
with patch.dict(sys.modules, {"tools.browser_tool": fake}):
_stub_urlopen(monkeypatch, ok=False)
resp = server.handle_request(
{
"id": "1",
"method": "browser.manage",
"params": {"action": "connect", "url": "http://unreachable:9222"},
}
)
assert "error" in resp
# Env preserved; nothing reaped.
assert os.environ["BROWSER_CDP_URL"] == "http://existing:9222"
assert cleanup_calls == []
def test_browser_manage_connect_normalizes_bare_host_port(monkeypatch):
"""Persist a parsed `scheme://host:port` URL so `_get_cdp_override`
can normalize it; storing a bare host:port would break subsequent
tool calls (Copilot review on #17120)."""
monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
fake = types.SimpleNamespace(
cleanup_all_browsers=lambda: None,
_get_cdp_override=lambda: os.environ.get("BROWSER_CDP_URL", ""),
)
with patch.dict(sys.modules, {"tools.browser_tool": fake}):
_stub_urlopen(monkeypatch, ok=True)
resp = server.handle_request(
{
"id": "1",
"method": "browser.manage",
"params": {"action": "connect", "url": "127.0.0.1:9222"},
}
)
assert resp["result"]["connected"] is True
# Bare host:port got promoted to a full URL with explicit scheme.
assert resp["result"]["url"].startswith("http://")
assert os.environ["BROWSER_CDP_URL"].startswith("http://")
def test_browser_manage_connect_strips_discovery_path(monkeypatch):
"""User-supplied discovery paths like `/json` or `/json/version`
must collapse to bare `scheme://host:port`; otherwise
``_resolve_cdp_override`` will append ``/json/version`` again and
produce a duplicate path (Copilot review round-2 on #17120)."""
monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
fake = types.SimpleNamespace(
cleanup_all_browsers=lambda: None,
_get_cdp_override=lambda: os.environ.get("BROWSER_CDP_URL", ""),
)
with patch.dict(sys.modules, {"tools.browser_tool": fake}):
_stub_urlopen(monkeypatch, ok=True)
resp = server.handle_request(
{
"id": "1",
"method": "browser.manage",
"params": {"action": "connect", "url": "http://127.0.0.1:9222/json"},
}
)
assert resp["result"]["connected"] is True
assert resp["result"]["url"] == "http://127.0.0.1:9222"
assert os.environ["BROWSER_CDP_URL"] == "http://127.0.0.1:9222"
def test_browser_manage_connect_preserves_devtools_browser_endpoint(monkeypatch):
"""Concrete devtools websocket endpoints (e.g. Browserbase) must
survive verbatim we only collapse discovery-style paths."""
monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
fake = types.SimpleNamespace(
cleanup_all_browsers=lambda: None,
_get_cdp_override=lambda: os.environ.get("BROWSER_CDP_URL", ""),
)
concrete = "ws://browserbase.example/devtools/browser/abc123"
class _OkSocket:
def __enter__(self): return self
def __exit__(self, *a): return False
with patch.dict(sys.modules, {"tools.browser_tool": fake}):
# If urlopen is reached for a concrete ws endpoint, the test
# would still pass because _stub_urlopen returned ok=True before;
# patch it to assert-fail so we prove the HTTP probe is skipped.
with patch("urllib.request.urlopen", side_effect=AssertionError("urlopen called")):
with patch("socket.create_connection", return_value=_OkSocket()):
resp = server.handle_request(
{
"id": "1",
"method": "browser.manage",
"params": {"action": "connect", "url": concrete},
}
)
assert resp["result"]["connected"] is True
assert resp["result"]["url"] == concrete
assert os.environ["BROWSER_CDP_URL"] == concrete
def test_browser_manage_connect_concrete_ws_skips_http_probe(monkeypatch):
"""Regression for round-2 Copilot review: a hosted CDP endpoint
(no HTTP discovery) must connect via TCP-only reachability check.
The HTTP probe used to reject these even though they're valid."""
monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
fake = types.SimpleNamespace(
cleanup_all_browsers=lambda: None,
_get_cdp_override=lambda: os.environ.get("BROWSER_CDP_URL", ""),
)
concrete = "wss://chrome.browserless.io/devtools/browser/sess-1"
seen_targets: list[tuple[str, int]] = []
class _OkSocket:
def __enter__(self): return self
def __exit__(self, *a): return False
def _fake_create_connection(addr, timeout=None):
seen_targets.append(addr)
return _OkSocket()
with patch.dict(sys.modules, {"tools.browser_tool": fake}):
# urlopen would 404/ECONNREFUSED on a real hosted CDP endpoint;
# asserting it's never called proves the probe was skipped.
with patch("urllib.request.urlopen", side_effect=AssertionError("urlopen called")):
with patch("socket.create_connection", side_effect=_fake_create_connection):
resp = server.handle_request(
{
"id": "1",
"method": "browser.manage",
"params": {"action": "connect", "url": concrete},
}
)
assert resp["result"] == {"connected": True, "url": concrete}
# wss → port 443, host preserved verbatim.
assert seen_targets == [("chrome.browserless.io", 443)]
def test_browser_manage_connect_concrete_ws_tcp_unreachable(monkeypatch):
"""If the TCP reachability check fails for a concrete ws endpoint,
return a clear 5031 error no fallback to the HTTP probe (which
can never succeed for these URLs anyway)."""
monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
fake = types.SimpleNamespace(
cleanup_all_browsers=lambda: None,
_get_cdp_override=lambda: os.environ.get("BROWSER_CDP_URL", ""),
)
concrete = "ws://offline.example/devtools/browser/missing"
with patch.dict(sys.modules, {"tools.browser_tool": fake}):
with patch("socket.create_connection", side_effect=OSError("ECONNREFUSED")):
resp = server.handle_request(
{
"id": "1",
"method": "browser.manage",
"params": {"action": "connect", "url": concrete},
}
)
assert "error" in resp
assert resp["error"]["code"] == 5031
def test_browser_manage_disconnect_drops_env_and_cleans(monkeypatch):
monkeypatch.setenv("BROWSER_CDP_URL", "http://127.0.0.1:9222")
cleanup_count = {"n": 0}
fake = types.SimpleNamespace(
cleanup_all_browsers=lambda: cleanup_count.__setitem__("n", cleanup_count["n"] + 1),
_get_cdp_override=lambda: os.environ.get("BROWSER_CDP_URL", ""),
)
with patch.dict(sys.modules, {"tools.browser_tool": fake}):
resp = server.handle_request(
{"id": "1", "method": "browser.manage", "params": {"action": "disconnect"}}
)
assert resp["result"] == {"connected": False}
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"]

View file

@ -21,6 +21,7 @@ import pytest
REPO_ROOT = Path(__file__).resolve().parents[2] REPO_ROOT = Path(__file__).resolve().parents[2]
DOCKERFILE = REPO_ROOT / "Dockerfile" DOCKERFILE = REPO_ROOT / "Dockerfile"
DOCKERIGNORE = REPO_ROOT / ".dockerignore"
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
@ -108,17 +109,37 @@ def test_dockerfile_installs_tui_dependencies(dockerfile_text):
assert "ui-tui/package.json" in dockerfile_text assert "ui-tui/package.json" in dockerfile_text
assert "ui-tui/packages/hermes-ink/package-lock.json" in dockerfile_text assert "ui-tui/packages/hermes-ink/package-lock.json" in dockerfile_text
assert any( assert any(
"ui-tui" in step "ui-tui" in step and "npm" in step and (" install" in step or " ci" in step)
and "npm" in step
and (" install" in step or " ci" in step)
for step in _run_steps(dockerfile_text) for step in _run_steps(dockerfile_text)
) )
def test_dockerfile_builds_tui_assets(dockerfile_text): def test_dockerfile_builds_tui_assets(dockerfile_text):
assert any( assert any(
"ui-tui" in step "ui-tui" in step and "npm" in step and "run build" in step
and "npm" in step
and "run build" in step
for step in _run_steps(dockerfile_text) for step in _run_steps(dockerfile_text)
) )
def test_dockerfile_materializes_local_tui_ink_package(dockerfile_text):
assert any(
"ui-tui" in step
and "node_modules/@hermes/ink" in step
and "packages/hermes-ink" in step
and "rm -rf packages/hermes-ink/node_modules" in step
and "npm install --omit=dev" in step
and "--prefix node_modules/@hermes/ink" in step
and "rm -rf node_modules/@hermes/ink/node_modules/react" in step
and "await import('@hermes/ink')" in step
for step in _run_steps(dockerfile_text)
)
def test_dockerignore_excludes_nested_dependency_dirs():
if not DOCKERIGNORE.exists():
pytest.skip(".dockerignore not present in this checkout")
text = DOCKERIGNORE.read_text()
assert "**/node_modules" in text
assert "**/.venv" in text

View file

@ -83,6 +83,134 @@ def test_write_json_broken_pipe(server):
assert server.write_json({"x": 1}) is False 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 ──────────────────────────────────────────────────────────── # ── _emit ────────────────────────────────────────────────────────────

View file

@ -164,6 +164,18 @@ HARDLINE_PATTERNS = [
(_CMDPOS + r'telinit\s+[06]\b', "telinit 0/6 (shutdown/reboot)"), (_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: def detect_hardline_command(command: str) -> tuple:
"""Check if a command matches the unconditional hardline blocklist. """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) (is_hardline, description) or (False, None)
""" """
normalized = _normalize_command_for_detection(command).lower() normalized = _normalize_command_for_detection(command).lower()
for pattern, description in HARDLINE_PATTERNS: for pattern_re, description in HARDLINE_PATTERNS_COMPILED:
if re.search(pattern, normalized, re.IGNORECASE | re.DOTALL): if pattern_re.search(normalized):
return (True, description) return (True, description)
return (False, None) 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: def _legacy_pattern_key(pattern: str) -> str:
"""Reproduce the old regex-derived approval key for backwards compatibility.""" """Reproduce the old regex-derived approval key for backwards compatibility."""
return pattern.split(r'\b')[1] if r'\b' in pattern else pattern[:20] 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) (is_dangerous, pattern_key, description) or (False, None, None)
""" """
command_lower = _normalize_command_for_detection(command).lower() command_lower = _normalize_command_for_detection(command).lower()
for pattern, description in DANGEROUS_PATTERNS: for pattern_re, description in DANGEROUS_PATTERNS_COMPILED:
if re.search(pattern, command_lower, re.IGNORECASE | re.DOTALL): if pattern_re.search(command_lower):
pattern_key = description pattern_key = description
return (True, pattern_key, description) return (True, pattern_key, description)
return (False, None, None) return (False, None, None)

View file

@ -19,6 +19,7 @@ import importlib
import json import json
import logging import logging
import threading import threading
import time
from pathlib import Path from pathlib import Path
from typing import Callable, Dict, List, Optional, Set from typing import Callable, Dict, List, Optional, Set
@ -97,6 +98,48 @@ class ToolEntry:
self.max_result_size_chars = max_result_size_chars 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: class ToolRegistry:
"""Singleton registry that collects tool schemas + handlers from tool files.""" """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 # reading tool metadata, so keep mutations serialized and readers on
# stable snapshots. # stable snapshots.
self._lock = threading.RLock() 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]]: def _snapshot_state(self) -> tuple[List[ToolEntry], Dict[str, Callable]]:
"""Return a coherent snapshot of registry entries and toolset checks.""" """Return a coherent snapshot of registry entries and toolset checks."""
@ -158,6 +207,7 @@ class ToolRegistry:
alias, existing, toolset, alias, existing, toolset,
) )
self._toolset_aliases[alias] = toolset self._toolset_aliases[alias] = toolset
self._generation += 1
def get_registered_toolset_aliases(self) -> Dict[str, str]: def get_registered_toolset_aliases(self) -> Dict[str, str]:
"""Return a snapshot of ``{alias: canonical_toolset}`` mappings.""" """Return a snapshot of ``{alias: canonical_toolset}`` mappings."""
@ -225,6 +275,7 @@ class ToolRegistry:
) )
if check_fn and toolset not in self._toolset_checks: if check_fn and toolset not in self._toolset_checks:
self._toolset_checks[toolset] = check_fn self._toolset_checks[toolset] = check_fn
self._generation += 1
def deregister(self, name: str) -> None: def deregister(self, name: str) -> None:
"""Remove a tool from the registry. """Remove a tool from the registry.
@ -249,6 +300,7 @@ class ToolRegistry:
for alias, target in self._toolset_aliases.items() for alias, target in self._toolset_aliases.items()
if target != entry.toolset if target != entry.toolset
} }
self._generation += 1
logger.debug("Deregistered tool: %s", name) logger.debug("Deregistered tool: %s", name)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -259,9 +311,17 @@ class ToolRegistry:
"""Return OpenAI-format tool schemas for the requested tool names. """Return OpenAI-format tool schemas for the requested tool names.
Only tools whose ``check_fn()`` returns True (or have no check_fn) 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 = [] 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] = {} check_results: Dict[Callable, bool] = {}
entries_by_name = {entry.name: entry for entry in self._snapshot_entries()} entries_by_name = {entry.name: entry for entry in self._snapshot_entries()}
for name in sorted(tool_names): for name in sorted(tool_names):
@ -270,12 +330,7 @@ class ToolRegistry:
continue continue
if entry.check_fn: if entry.check_fn:
if entry.check_fn not in check_results: if entry.check_fn not in check_results:
try: check_results[entry.check_fn] = _check_fn_cached(entry.check_fn)
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)
if not check_results[entry.check_fn]: if not check_results[entry.check_fn]:
if not quiet: if not quiet:
logger.debug("Tool %s unavailable (check failed)", name) logger.debug("Tool %s unavailable (check failed)", name)

View file

@ -45,12 +45,47 @@ import logging
import os import os
import re import re
import asyncio import asyncio
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional, TYPE_CHECKING
import httpx import httpx
# NOTE: `from firecrawl import Firecrawl` is deliberately NOT at module top — # 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 # 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 # we only need it when the backend is actually "firecrawl". We expose
# _get_firecrawl_client() below for the lazy import. # ``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 ( from agent.auxiliary_client import (
async_call_llm, async_call_llm,
extract_content_or_reasoning, extract_content_or_reasoning,
@ -239,8 +274,7 @@ def _get_firecrawl_client():
if _firecrawl_client is not None and _firecrawl_client_config == client_config: if _firecrawl_client is not None and _firecrawl_client_config == client_config:
return _firecrawl_client return _firecrawl_client
# Lazy import — ~200 ms of SDK init, only paid when firecrawl is actually used. # Uses the module-level `Firecrawl` name (lazy proxy at module top).
from firecrawl import Firecrawl # noqa: E402
_firecrawl_client = Firecrawl(**kwargs) _firecrawl_client = Firecrawl(**kwargs)
_firecrawl_client_config = client_config _firecrawl_client_config = client_config
return _firecrawl_client return _firecrawl_client

View file

@ -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: def _log_signal(signum: int, frame) -> None:
"""Capture WHICH thread and WHERE a termination signal hit us. """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 handler the gateway-exited banner in the TUI has no trace the
crash log never sees a Python exception because the kernel reaps crash log never sees a Python exception because the kernel reaps
the process before the interpreter runs anything. 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 = { name = {
signal.SIGPIPE: "SIGPIPE", signal.SIGPIPE: "SIGPIPE",
@ -62,7 +93,31 @@ def _log_signal(signum: int, frame) -> None:
except Exception: except Exception:
pass pass
print(f"[gateway-signal] {name}", file=sys.stderr, flush=True) 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 # SIGPIPE: ignore, don't exit. The old SIG_DFL killed the process

View file

@ -491,6 +491,13 @@ def _normalize_completion_path(path_part: str) -> str:
# ── Config I/O ──────────────────────────────────────────────────────── # ── 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: def _load_cfg() -> dict:
global _cfg_cache, _cfg_mtime, _cfg_path global _cfg_cache, _cfg_mtime, _cfg_path
try: try:
@ -683,6 +690,21 @@ def _coerce_statusbar(raw) -> str:
return "top" 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: def _load_reasoning_config() -> dict | None:
from hermes_constants import parse_reasoning_effort from hermes_constants import parse_reasoning_effort
@ -1788,6 +1810,50 @@ def _(rid, params: dict) -> dict:
return _err(rid, 5006, str(e)) return _err(rid, 5006, str(e))
@method("session.most_recent")
def _(rid, params: dict) -> dict:
"""Return the most recent human-facing session id, or ``None``.
Mirrors ``session.list``'s deny-list behaviour (drops ``tool``
sub-agent rows). Used by TUI auto-resume when
``display.tui_auto_resume_recent`` is on; the field is also handy
for any CLI tooling that wants "latest session" without paginating
the full list.
Contract: a ``{"session_id": null}`` result means "no eligible
session found right now". Errors are also folded into that
null-result shape (and logged) so callers don't have to special-
case JSON-RPC error envelopes for what is a normal "no answer".
"""
db = _get_db()
if db is None:
return _ok(rid, {"session_id": None})
try:
deny = frozenset({"tool"})
# Over-fetch by a generous bounded amount so heavy sub-agent
# users (lots of recent ``tool`` rows) don't get a false
# "no eligible session" answer. ``session.list`` uses a
# similar over-fetch strategy.
rows = db.list_sessions_rich(source=None, limit=200)
for row in rows:
src = (row.get("source") or "").strip().lower()
if src in deny:
continue
return _ok(
rid,
{
"session_id": row.get("id"),
"title": row.get("title") or "",
"started_at": row.get("started_at") or 0,
"source": row.get("source") or "",
},
)
return _ok(rid, {"session_id": None})
except Exception:
logger.exception("session.most_recent failed")
return _ok(rid, {"session_id": None})
@method("session.resume") @method("session.resume")
def _(rid, params: dict) -> dict: def _(rid, params: dict) -> dict:
target = params.get("session_id", "") target = params.get("session_id", "")
@ -3121,12 +3187,9 @@ def _(rid, params: dict) -> dict:
if key == "mouse": if key == "mouse":
raw = str(value or "").strip().lower() raw = str(value or "").strip().lower()
display = ( cfg = _load_cfg()
_load_cfg().get("display") display = cfg.get("display") if isinstance(cfg.get("display"), dict) else {}
if isinstance(_load_cfg().get("display"), dict) current = _display_mouse_tracking(display)
else {}
)
current = bool(display.get("tui_mouse", True))
if raw in ("", "toggle"): if raw in ("", "toggle"):
nv = not current nv = not current
@ -3137,9 +3200,22 @@ def _(rid, params: dict) -> dict:
else: else:
return _err(rid, 4002, f"unknown mouse value: {value}") 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"}) 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"): if key in ("prompt", "personality", "skin"):
try: try:
cfg = _load_cfg() cfg = _load_cfg()
@ -3210,6 +3286,18 @@ def _(rid, params: dict) -> dict:
return _ok( return _ok(
rid, {"value": (_load_cfg().get("display") or {}).get("skin", "default")} 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": if key == "personality":
return _ok( return _ok(
rid, rid,
@ -3285,7 +3373,7 @@ def _(rid, params: dict) -> dict:
return _ok(rid, {"value": _coerce_statusbar(raw)}) return _ok(rid, {"value": _coerce_statusbar(raw)})
if key == "mouse": if key == "mouse":
display = _load_cfg().get("display") 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"}) return _ok(rid, {"value": "on" if on else "off"})
if key == "mtime": if key == "mtime":
cfg_path = _hermes_home / "config.yaml" cfg_path = _hermes_home / "config.yaml"
@ -3354,6 +3442,7 @@ _TUI_HIDDEN: frozenset[str] = frozenset(
_TUI_EXTRA: list[tuple[str, str, str]] = [ _TUI_EXTRA: list[tuple[str, str, str]] = [
("/compact", "Toggle compact display mode", "TUI"), ("/compact", "Toggle compact display mode", "TUI"),
("/logs", "Show recent gateway log lines", "TUI"), ("/logs", "Show recent gateway log lines", "TUI"),
("/mouse", "Toggle mouse/wheel tracking [on|off|toggle]", "TUI"),
] ]
# Commands that queue messages onto _pending_input in the CLI. # Commands that queue messages onto _pending_input in the CLI.
@ -4133,6 +4222,11 @@ def _(rid, params: dict) -> dict:
"display": "/logs", "display": "/logs",
"meta": "Show recent gateway log lines", "meta": "Show recent gateway log lines",
}, },
{
"text": "/mouse",
"display": "/mouse",
"meta": "Toggle mouse/wheel tracking [on|off|toggle]",
},
] ]
for extra in extras: for extra in extras:
if extra["text"].startswith(text_lower) and not any( if extra["text"].startswith(text_lower) and not any(
@ -4624,12 +4718,51 @@ def _(rid, params: dict) -> dict:
# ── Methods: browser / plugins / cron / skills ─────────────────────── # ── Methods: browser / plugins / cron / skills ───────────────────────
def _resolve_browser_cdp_url() -> str:
"""Return the configured browser CDP override without network I/O.
``/browser status`` must be fast calling
``tools.browser_tool._get_cdp_override`` would invoke
``_resolve_cdp_override``, which performs an HTTP probe to
``.../json/version`` for discovery-style URLs. That probe has
a multi-second timeout and would block the TUI on a slow or
unreachable host even though status only needs to report whether
an override is set.
Mirrors the env/config precedence of ``_get_cdp_override`` (env
var first, then ``browser.cdp_url`` from config.yaml) without the
websocket-resolution step, so the answer reflects user intent
even when the configured host is not currently reachable. The
actual WS normalization happens in ``browser_navigate`` on the
next tool call.
"""
env_url = os.environ.get("BROWSER_CDP_URL", "").strip()
if env_url:
return env_url
try:
from hermes_cli.config import read_raw_config
cfg = read_raw_config()
browser_cfg = cfg.get("browser", {}) if isinstance(cfg, dict) else {}
if isinstance(browser_cfg, dict):
return str(browser_cfg.get("cdp_url", "") or "").strip()
except Exception:
pass
return ""
@method("browser.manage") @method("browser.manage")
def _(rid, params: dict) -> dict: def _(rid, params: dict) -> dict:
action = params.get("action", "status") action = params.get("action", "status")
if action == "status": if action == "status":
url = os.environ.get("BROWSER_CDP_URL", "") resolved_url = _resolve_browser_cdp_url()
return _ok(rid, {"connected": bool(url), "url": url}) return _ok(
rid,
{
"connected": bool(resolved_url),
"url": resolved_url,
},
)
if action == "connect": if action == "connect":
url = params.get("url", "http://localhost:9222") url = params.get("url", "http://localhost:9222")
try: try:
@ -4640,36 +4773,97 @@ def _(rid, params: dict) -> dict:
parsed = urlparse(url if "://" in url else f"http://{url}") parsed = urlparse(url if "://" in url else f"http://{url}")
if parsed.scheme not in {"http", "https", "ws", "wss"}: if parsed.scheme not in {"http", "https", "ws", "wss"}:
return _err(rid, 4015, f"unsupported browser url: {url}") return _err(rid, 4015, f"unsupported browser url: {url}")
probe_root = f"{'https' if parsed.scheme == 'wss' else 'http' if parsed.scheme == 'ws' else parsed.scheme}://{parsed.netloc}"
probe_urls = [
f"{probe_root.rstrip('/')}/json/version",
f"{probe_root.rstrip('/')}/json",
]
ok = False
for probe in probe_urls:
try:
with urllib.request.urlopen(probe, timeout=2.0) as resp:
if 200 <= getattr(resp, "status", 200) < 300:
ok = True
break
except Exception:
continue
if not ok:
return _err(rid, 5031, f"could not reach browser CDP at {url}")
os.environ["BROWSER_CDP_URL"] = url # A concrete ``ws[s]://.../devtools/browser/<id>`` endpoint is
# already directly connectable — those are the URLs Browserbase
# / browserless / hosted CDP providers return, and they
# generally DON'T serve the discovery-style ``/json/version``
# path. Probing it would just reject valid endpoints. Skip
# the HTTP probe and do a TCP-level reachability check instead;
# the actual CDP handshake happens on the next ``browser_navigate``.
is_concrete_ws = (
parsed.scheme in {"ws", "wss"}
and parsed.path.startswith("/devtools/browser/")
)
if is_concrete_ws:
import socket
host = parsed.hostname
port = parsed.port or (443 if parsed.scheme == "wss" else 80)
if not host:
return _err(rid, 4015, f"missing host in browser url: {url}")
try:
with socket.create_connection((host, port), timeout=2.0):
pass
except OSError as e:
return _err(rid, 5031, f"could not reach browser CDP at {url}: {e}")
else:
probe_root = f"{'https' if parsed.scheme == 'wss' else 'http' if parsed.scheme == 'ws' else parsed.scheme}://{parsed.netloc}"
probe_urls = [
f"{probe_root.rstrip('/')}/json/version",
f"{probe_root.rstrip('/')}/json",
]
ok = False
for probe in probe_urls:
try:
with urllib.request.urlopen(probe, timeout=2.0) as resp:
if 200 <= getattr(resp, "status", 200) < 300:
ok = True
break
except Exception:
continue
if not ok:
return _err(rid, 5031, f"could not reach browser CDP at {url}")
# Persist a normalized URL for downstream CDP resolution.
# Discovery-style inputs (`http://host:port` or
# `http://host:port/json[/version]`) collapse to bare
# ``scheme://host:port`` so ``_resolve_cdp_override`` can
# safely append ``/json/version`` without producing a
# double-discovery path like ``.../json/json/version``.
# Concrete websocket endpoints (``/devtools/browser/<id>``
# — what Browserbase and other cloud providers return)
# are preserved verbatim.
if parsed.path.startswith("/devtools/browser/"):
normalized = parsed.geturl()
else:
normalized = parsed._replace(
path="",
params="",
query="",
fragment="",
).geturl()
# Order matters: clear any cached browser sessions BEFORE
# publishing the new env var so an in-flight tool call
# observing the old supervisor is reaped first, and the
# next call freshly resolves the new URL. The previous
# ordering left a brief window where ``_ensure_cdp_supervisor``
# could re-attach to the *old* supervisor.
cleanup_all_browsers()
os.environ["BROWSER_CDP_URL"] = normalized
# Drain any further cached state that could outlive the
# cleanup pass (CDP supervisor for the default task,
# cached agent-browser timeouts, etc.) so the next
# ``browser_navigate`` definitively reaches ``normalized``.
cleanup_all_browsers() cleanup_all_browsers()
except Exception as e: except Exception as e:
return _err(rid, 5031, str(e)) return _err(rid, 5031, str(e))
return _ok(rid, {"connected": True, "url": url}) return _ok(rid, {"connected": True, "url": normalized})
if action == "disconnect": if action == "disconnect":
os.environ.pop("BROWSER_CDP_URL", None)
try: try:
from tools.browser_tool import cleanup_all_browsers from tools.browser_tool import cleanup_all_browsers
cleanup_all_browsers() cleanup_all_browsers()
except Exception: except Exception:
pass pass
os.environ.pop("BROWSER_CDP_URL", None)
try:
from tools.browser_tool import cleanup_all_browsers as _again
_again()
except Exception:
pass
return _ok(rid, {"connected": False}) return _ok(rid, {"connected": False})
return _err(rid, 4015, f"unknown action: {action}") return _err(rid, 4015, f"unknown action: {action}")

View file

@ -23,10 +23,45 @@ the stream lazily through a callback.
from __future__ import annotations from __future__ import annotations
import contextvars import contextvars
import errno
import json import json
import logging
import os
import threading import threading
from typing import Any, Callable, Optional, Protocol, runtime_checkable 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 @runtime_checkable
class Transport(Protocol): class Transport(Protocol):
@ -77,15 +112,72 @@ class StdioTransport:
self._lock = lock self._lock = lock
def write(self, obj: dict) -> bool: 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" line = json.dumps(obj, ensure_ascii=False) + "\n"
try:
with self._lock: with self._lock:
stream = self._stream_getter() stream = self._stream_getter()
try:
stream.write(line) stream.write(line)
stream.flush() except BrokenPipeError:
return True return False
except BrokenPipeError: except ValueError as e:
return False # 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: def close(self) -> None:
return None return None

View file

@ -39,6 +39,15 @@ describe('enhanced keyboard modifier parsing', () => {
expect(event.key.super).toBe(true) 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', () => { it('preserves Cmd on word-delete and word-navigation sequences', () => {
const backspace = new InputEvent(parseOne('\u001b[127;9u')) const backspace = new InputEvent(parseOne('\u001b[127;9u'))
const left = new InputEvent(parseOne('\u001b[1;9D')) const left = new InputEvent(parseOne('\u001b[1;9D'))

View file

@ -35,6 +35,8 @@ export function useSelection(): {
* replaces the old SGR-7 inverse so syntax highlighting stays readable * replaces the old SGR-7 inverse so syntax highlighting stays readable
* under selection). Call once on mount + whenever theme changes. */ * under selection). Call once on mount + whenever theme changes. */
setSelectionBgColor: (color: string) => void 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. // Look up the Ink instance via stdout — same pattern as instances map.
// StdinContext is available (it's always provided), and the Ink instance // StdinContext is available (it's always provided), and the Ink instance
@ -58,7 +60,8 @@ export function useSelection(): {
shiftSelection: () => {}, shiftSelection: () => {},
moveFocus: () => {}, moveFocus: () => {},
captureScrolledRows: () => {}, captureScrolledRows: () => {},
setSelectionBgColor: () => {} setSelectionBgColor: () => {},
version: () => 0
} }
} }
@ -73,7 +76,8 @@ export function useSelection(): {
shiftSelection: (dRow, minRow, maxRow) => ink.shiftSelectionForScroll(dRow, minRow, maxRow), shiftSelection: (dRow, minRow, maxRow) => ink.shiftSelectionForScroll(dRow, minRow, maxRow),
moveFocus: (move: FocusMove) => ink.moveSelectionFocus(move), moveFocus: (move: FocusMove) => ink.moveSelectionFocus(move),
captureScrolledRows: (firstRow, lastRow, side) => ink.captureScrolledRows(firstRow, lastRow, side), 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]) }, [ink])
} }

View file

@ -63,6 +63,7 @@ import {
hasSelection, hasSelection,
moveFocus, moveFocus,
selectionBounds, selectionBounds,
selectionSignature,
type SelectionState, type SelectionState,
selectLineAt, selectLineAt,
selectWordAt, selectWordAt,
@ -213,7 +214,8 @@ export default class Ink {
// Fired alongside the terminal repaint whenever the selection mutates // Fired alongside the terminal repaint whenever the selection mutates
// so UI (e.g. footer hints) can react to selection appearing/clearing. // so UI (e.g. footer hints) can react to selection appearing/clearing.
private readonly selectionListeners = new Set<() => void>() 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 // DOM nodes currently under the pointer (mode-1003 motion). Held here
// so App.tsx's handleMouseEvent is stateless — dispatchHover diffs // so App.tsx's handleMouseEvent is stateless — dispatchHover diffs
// against this set and mutates it in place. // against this set and mutates it in place.
@ -1661,9 +1663,16 @@ export default class Ink {
return hasSelection(this.selection) return hasSelection(this.selection)
} }
getSelectionVersion(): number {
return this.selectionVersion
}
/** /**
* Subscribe to selection state changes. Fires whenever the selection * 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 { subscribeToSelectionChange(cb: () => void): () => void {
this.selectionListeners.add(cb) this.selectionListeners.add(cb)
@ -1673,14 +1682,18 @@ export default class Ink {
private notifySelectionChange(): void { private notifySelectionChange(): void {
this.scheduleRender() 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) { if (sig !== this.lastSelectionSignature) {
this.selectionWasActive = active this.lastSelectionSignature = sig
this.selectionVersion += 1
}
for (const cb of this.selectionListeners) { for (const cb of this.selectionListeners) {
cb() cb()
}
} }
} }

View file

@ -799,6 +799,20 @@ export function hasSelection(s: SelectionState): boolean {
return s.anchor !== null && s.focus !== null 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. * Normalized selection bounds: start is always before end in reading order.
* Returns null if no active selection. * Returns null if no active selection.

View file

@ -293,6 +293,69 @@ describe('createGatewayEventHandler', () => {
expect(appended[1]).toMatchObject({ role: 'assistant', text: 'final answer' }) expect(appended[1]).toMatchObject({ role: 'assistant', text: 'final answer' })
}) })
it('annotates gateway.start_timeout with stderr tail lines so users can diagnose without /logs', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({
payload: {
cwd: '/repo',
python: '/opt/venv/bin/python',
stderr_tail:
'[startup] timed out\nModuleNotFoundError: No module named openai\nFileNotFoundError: ~/.hermes/config.yaml'
},
type: 'gateway.start_timeout'
} as any)
const messages = getTurnState().activity.map(a => a.text)
expect(messages.some(m => m.includes('gateway startup timed out'))).toBe(true)
expect(messages.some(m => m.includes('ModuleNotFoundError'))).toBe(true)
expect(messages.some(m => m.includes('FileNotFoundError'))).toBe(true)
})
it('prefers raw text over Rich-rendered ANSI on message.complete (#16391)', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
const raw = 'Hermes here.\n\nLine two.'
// Rich-rendered ANSI (`final_response_markdown: render`) used to win,
// which left visible escape codes in Ink output. Raw text must win.
const rendered = '\u001b[33mHermes here.\u001b[0m\n\n\u001b[2mLine two.\u001b[0m'
onEvent({ payload: { rendered, text: raw }, type: 'message.complete' } as any)
const assistant = appended.find(msg => msg.role === 'assistant')
expect(assistant?.text).toBe(raw)
expect(assistant?.text).not.toContain('\u001b[')
})
it('falls back to payload.rendered when text is missing on message.complete', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
const rendered = 'fallback when gateway omitted text'
onEvent({ payload: { rendered }, type: 'message.complete' } as any)
const assistant = appended.find(msg => msg.role === 'assistant')
expect(assistant?.text).toBe(rendered)
})
it('always accumulates raw text in message.delta and ignores `rendered` (#16391)', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
// Stream of partial text deltas; each delta carries an incremental
// Rich-ANSI fragment. Pre-fix code would replace the whole bufRef
// with the latest fragment, dropping prior text.
onEvent({ payload: { rendered: '\u001b[33mFi\u001b[0m', text: 'Fi' }, type: 'message.delta' } as any)
onEvent({ payload: { rendered: '\u001b[33mrst.\u001b[0m', text: 'rst.' }, type: 'message.delta' } as any)
onEvent({ payload: { text: ' second.' }, type: 'message.delta' } as any)
onEvent({ payload: {}, type: 'message.complete' } as any)
const assistant = appended.find(msg => msg.role === 'assistant')
expect(assistant?.text).toBe('First. second.')
})
it('anchors inline_diff as its own segment where the edit happened', () => { it('anchors inline_diff as its own segment where the edit happened', () => {
const appended: Msg[] = [] const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended)) const onEvent = createGatewayEventHandler(buildCtx(appended))
@ -437,6 +500,152 @@ describe('createGatewayEventHandler', () => {
}) })
}) })
it('on gateway.ready with no STARTUP_RESUME_ID and auto_resume off, forges a new session', async () => {
const appended: Msg[] = []
const newSession = vi.fn()
const resumeById = vi.fn()
const ctx = buildCtx(appended)
ctx.session.newSession = newSession
ctx.session.resumeById = resumeById
ctx.session.STARTUP_RESUME_ID = ''
ctx.gateway.rpc = vi.fn(async (method: string) => {
if (method === 'config.get') {
return { config: { display: { tui_auto_resume_recent: false } } }
}
return null
})
createGatewayEventHandler(ctx)({ payload: {}, type: 'gateway.ready' } as any)
await vi.waitFor(() => expect(newSession).toHaveBeenCalled())
expect(resumeById).not.toHaveBeenCalled()
})
it('on gateway.ready with auto_resume on and a recent session, resumes it', async () => {
const appended: Msg[] = []
const newSession = vi.fn()
const resumeById = vi.fn()
const ctx = buildCtx(appended)
ctx.session.newSession = newSession
ctx.session.resumeById = resumeById
ctx.session.STARTUP_RESUME_ID = ''
ctx.gateway.rpc = vi.fn(async (method: string) => {
if (method === 'config.get') {
return { config: { display: { tui_auto_resume_recent: true } } }
}
if (method === 'session.most_recent') {
return { session_id: 'sess-most-recent' }
}
return null
})
createGatewayEventHandler(ctx)({ payload: {}, type: 'gateway.ready' } as any)
await vi.waitFor(() => expect(resumeById).toHaveBeenCalledWith('sess-most-recent'))
expect(newSession).not.toHaveBeenCalled()
})
it('on gateway.ready with auto_resume on but no eligible session, falls back to new', async () => {
const appended: Msg[] = []
const newSession = vi.fn()
const resumeById = vi.fn()
const ctx = buildCtx(appended)
ctx.session.newSession = newSession
ctx.session.resumeById = resumeById
ctx.session.STARTUP_RESUME_ID = ''
ctx.gateway.rpc = vi.fn(async (method: string) => {
if (method === 'config.get') {
return { config: { display: { tui_auto_resume_recent: true } } }
}
if (method === 'session.most_recent') {
return { session_id: null }
}
return null
})
createGatewayEventHandler(ctx)({ payload: {}, type: 'gateway.ready' } as any)
await vi.waitFor(() => expect(newSession).toHaveBeenCalled())
expect(resumeById).not.toHaveBeenCalled()
})
it('on gateway.ready when config.get rejects, falls back to new session', async () => {
const appended: Msg[] = []
const newSession = vi.fn()
const resumeById = vi.fn()
const ctx = buildCtx(appended)
ctx.session.newSession = newSession
ctx.session.resumeById = resumeById
ctx.session.STARTUP_RESUME_ID = ''
ctx.gateway.rpc = vi.fn(async (method: string) => {
if (method === 'config.get') {
throw new Error('gateway timeout')
}
return null
})
createGatewayEventHandler(ctx)({ payload: {}, type: 'gateway.ready' } as any)
await vi.waitFor(() => expect(newSession).toHaveBeenCalled())
expect(resumeById).not.toHaveBeenCalled()
})
it('on gateway.ready when session.most_recent rejects, falls back to new session', async () => {
const appended: Msg[] = []
const newSession = vi.fn()
const resumeById = vi.fn()
const ctx = buildCtx(appended)
ctx.session.newSession = newSession
ctx.session.resumeById = resumeById
ctx.session.STARTUP_RESUME_ID = ''
ctx.gateway.rpc = vi.fn(async (method: string) => {
if (method === 'config.get') {
return { config: { display: { tui_auto_resume_recent: true } } }
}
if (method === 'session.most_recent') {
throw new Error('db locked')
}
return null
})
createGatewayEventHandler(ctx)({ payload: {}, type: 'gateway.ready' } as any)
await vi.waitFor(() => expect(newSession).toHaveBeenCalled())
expect(resumeById).not.toHaveBeenCalled()
})
it('on gateway.ready with STARTUP_RESUME_ID set, the env wins over config auto_resume', async () => {
const appended: Msg[] = []
const newSession = vi.fn()
const resumeById = vi.fn()
const ctx = buildCtx(appended)
ctx.session.newSession = newSession
ctx.session.resumeById = resumeById
ctx.session.STARTUP_RESUME_ID = 'env-explicit'
ctx.gateway.rpc = vi.fn(async () => ({
config: { display: { tui_auto_resume_recent: true } }
}))
createGatewayEventHandler(ctx)({ payload: {}, type: 'gateway.ready' } as any)
await vi.waitFor(() => expect(resumeById).toHaveBeenCalledWith('env-explicit'))
expect(newSession).not.toHaveBeenCalled()
})
it('keeps gateway noise informational and approval out of Activity', async () => { it('keeps gateway noise informational and approval out of Activity', async () => {
const appended: Msg[] = [] const appended: Msg[] = []
const ctx = buildCtx(appended) const ctx = buildCtx(appended)
@ -474,4 +683,87 @@ describe('createGatewayEventHandler', () => {
expect(getTurnState().activity).toMatchObject([{ text: 'boom', tone: 'error' }]) expect(getTurnState().activity).toMatchObject([{ text: 'boom', tone: 'error' }])
}) })
it('drops stale reasoning/tool/todos events after ctrl-c until the next message starts', () => {
// Repro for the discord report: ctrl-c interrupts, but late reasoning/tool
// events from the still-winding-down agent loop kept populating the UI for
// ~1s, making it look like the interrupt had been ignored.
//
// Fake timers because `interruptTurn` schedules a real setTimeout for
// its cooldown — without flushing it inside this test, the timeout
// can fire later and mutate uiStore/turnState during unrelated tests
// (cross-file flake).
vi.useFakeTimers()
try {
const appended: Msg[] = []
const ctx = buildCtx(appended)
ctx.gateway.gw.request = vi.fn(async () => ({ status: 'interrupted' }))
const onEvent = createGatewayEventHandler(ctx)
patchUiState({ sid: 'sess-1' })
onEvent({ payload: {}, type: 'message.start' } as any)
onEvent({
payload: {
context: 'pre',
name: 'search',
todos: [{ content: 'pre-interrupt', id: 'todo-1', status: 'pending' }],
tool_id: 't-1'
},
type: 'tool.start'
} as any)
// Pre-interrupt todos should land in turn state.
expect(getTurnState().todos).toEqual([
{ content: 'pre-interrupt', id: 'todo-1', status: 'pending' }
])
turnController.interruptTurn({
appendMessage: (msg: Msg) => appended.push(msg),
gw: ctx.gateway.gw,
sid: 'sess-1',
sys: ctx.system.sys
})
onEvent({ payload: { text: 'still thinking…' }, type: 'reasoning.delta' } as any)
// Post-interrupt tool.start with a todos payload — must NOT mutate todos.
onEvent({
payload: {
context: 'post',
name: 'browser',
todos: [{ content: 'late ghost', id: 'todo-ghost', status: 'pending' }],
tool_id: 't-2'
},
type: 'tool.start'
} as any)
// Late tool.generating must NOT push a 'drafting …' line into the trail.
const trailBefore = getTurnState().turnTrail.length
onEvent({ payload: { name: 'browser' }, type: 'tool.generating' } as any)
expect(getTurnState().turnTrail.length).toBe(trailBefore)
onEvent({ payload: { name: 'browser', preview: 'loading' }, type: 'tool.progress' } as any)
onEvent({ payload: { summary: 'done', tool_id: 't-2' }, type: 'tool.complete' } as any)
onEvent({ payload: { text: 'late chunk' }, type: 'message.delta' } as any)
expect(getTurnState().tools).toEqual([])
expect(turnController.reasoningText).toBe('')
expect(turnController.bufRef).toBe('')
expect(getTurnState().streamPendingTools).toEqual([])
expect(getTurnState().streamSegments).toEqual([])
// Stale post-interrupt todos must not have leaked through.
// (This test does not assert that pre-interrupt todos are cleared —
// current interrupt path leaves them visible until the next message.)
expect(getTurnState().todos.find(t => t.content === 'late ghost')).toBeUndefined()
onEvent({ payload: {}, type: 'message.start' } as any)
onEvent({ payload: { text: 'fresh' }, type: 'reasoning.delta' } as any)
expect(turnController.reasoningText).toBe('fresh')
} finally {
// Drain pending fake timers BEFORE restoring real timers so a mid-
// test assertion failure can't leak the interrupt-cooldown setTimeout
// across test files (the original Copilot concern).
vi.runAllTimers()
vi.useRealTimers()
}
})
}) })

View file

@ -195,7 +195,8 @@ describe('createSlashHandler', () => {
['/reload-mcp', 'reload.mcp', { session_id: null }], ['/reload-mcp', 'reload.mcp', { session_id: null }],
['/stop', 'process.stop', {}], ['/stop', 'process.stop', {}],
['/fast status', 'config.get', { key: 'fast', session_id: null }], ['/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) => { ])('routes %s through native RPC (no slash worker)', (command, method, params) => {
const rpc = vi.fn(() => Promise.resolve({})) const rpc = vi.fn(() => Promise.resolve({}))
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } }) const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
@ -215,6 +216,24 @@ describe('createSlashHandler', () => {
expect(ctx.gateway.gw.request).not.toHaveBeenCalled() 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 () => { it('drops stale slash.exec output after a newer slash', async () => {
let resolveLate: (v: { output?: string }) => void let resolveLate: (v: { output?: string }) => void
let slashExecCalls = 0 let slashExecCalls = 0

View 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()
}
)
})
})

View file

@ -51,6 +51,12 @@ describe('isCopyShortcut', () => {
expect(isCopyShortcut({ ctrl: false, meta: true, super: false }, 'c', {})).toBe(false) 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', () => { describe('isVoiceToggleKey', () => {

View file

@ -116,6 +116,6 @@ describe('streaming theme assumption', () => {
// Sanity that the theme we pass doesn't change shape. Component import // Sanity that the theme we pass doesn't change shape. Component import
// already happens above — this is a smoke test that the module graph // already happens above — this is a smoke test that the module graph
// for streamingMarkdown wires up without cycles. // for streamingMarkdown wires up without cycles.
expect(DEFAULT_THEME.color.amber).toBeTruthy() expect(DEFAULT_THEME.color.accent).toBeTruthy()
}) })
}) })

View file

@ -19,16 +19,16 @@ describe('syntax highlighter', () => {
it('paints a whole-line comment dim', () => { it('paints a whole-line comment dim', () => {
const tokens = highlightLine('// hello', 'ts', t) 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', () => { it('paints keywords, strings, and numbers in a ts line', () => {
const tokens = highlightLine(`const x = 'hi' + 42`, 'ts', t) const tokens = highlightLine(`const x = 'hi' + 42`, 'ts', t)
const colors = tokens.map(tok => tok[0]) const colors = tokens.map(tok => tok[0])
expect(colors).toContain(t.color.bronze) // const expect(colors).toContain(t.color.border) // const
expect(colors).toContain(t.color.amber) // 'hi' expect(colors).toContain(t.color.accent) // 'hi'
expect(colors).toContain(t.color.cornsilk) // 42 expect(colors).toContain(t.color.text) // 42
}) })
it('falls through unchanged for unknown langs', () => { it('falls through unchanged for unknown langs', () => {
@ -40,6 +40,6 @@ describe('syntax highlighter', () => {
it('treats `#` as a python comment, not a selector', () => { it('treats `#` as a python comment, not a selector', () => {
const tokens = highlightLine('# comment', 'py', t) const tokens = highlightLine('# comment', 'py', t)
expect(tokens).toEqual([[t.color.dim, '# comment']]) expect(tokens).toEqual([[t.color.muted, '# comment']])
}) })
}) })

View file

@ -28,6 +28,12 @@ describe('terminalParityHints', () => {
it('suppresses IDE setup hint when keybindings are already configured', async () => { it('suppresses IDE setup hint when keybindings are already configured', async () => {
const readFile = vi.fn().mockResolvedValue( const readFile = vi.fn().mockResolvedValue(
JSON.stringify([ JSON.stringify([
{
key: 'cmd+c',
command: 'workbench.action.terminal.sendSequence',
when: 'terminalFocus && terminalTextSelected',
args: { text: '\u001b[99;13u' }
},
{ {
key: 'shift+enter', key: 'shift+enter',
command: 'workbench.action.terminal.sendSequence', command: 'workbench.action.terminal.sendSequence',

View file

@ -79,11 +79,34 @@ describe('configureTerminalKeybindings', () => {
expect(writeFile).toHaveBeenCalledTimes(1) expect(writeFile).toHaveBeenCalledTimes(1)
expect(copyFile).not.toHaveBeenCalled() // no existing file to back up expect(copyFile).not.toHaveBeenCalled() // no existing file to back up
const written = writeFile.mock.calls[0]?.[1] as string 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('shift+enter')
expect(written).toContain('cmd+enter') expect(written).toContain('cmd+enter')
expect(written).toContain('cmd+z') 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 () => { it('reports conflicts without overwriting existing bindings', async () => {
const mkdir = vi.fn().mockResolvedValue(undefined) const mkdir = vi.fn().mockResolvedValue(undefined)
@ -113,6 +136,118 @@ describe('configureTerminalKeybindings', () => {
expect(copyFile).not.toHaveBeenCalled() // no backup when not writing 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 () => { it('backs up existing keybindings.json only when writing changes', async () => {
const mkdir = vi.fn().mockResolvedValue(undefined) const mkdir = vi.fn().mockResolvedValue(undefined)
const readFile = vi.fn().mockResolvedValue(JSON.stringify([])) const readFile = vi.fn().mockResolvedValue(JSON.stringify([]))
@ -186,6 +321,12 @@ describe('configureTerminalKeybindings', () => {
const readComplete = vi.fn().mockResolvedValue( const readComplete = vi.fn().mockResolvedValue(
JSON.stringify([ JSON.stringify([
{
key: 'cmd+c',
command: 'workbench.action.terminal.sendSequence',
when: 'terminalFocus && terminalTextSelected',
args: { text: '\u001b[99;13u' }
},
{ {
key: 'shift+enter', key: 'shift+enter',
command: 'workbench.action.terminal.sendSequence', command: 'workbench.action.terminal.sendSequence',

View file

@ -44,6 +44,7 @@ describe('input metrics helpers', () => {
it('reserves gutters on wide panes without starving narrow composer width', () => { it('reserves gutters on wide panes without starving narrow composer width', () => {
expect(stableComposerColumns(100, 3)).toBe(93) expect(stableComposerColumns(100, 3)).toBe(93)
expect(stableComposerColumns(100, 5)).toBe(91)
expect(stableComposerColumns(10, 3)).toBe(5) expect(stableComposerColumns(10, 3)).toBe(5)
expect(stableComposerColumns(6, 3)).toBe(1) expect(stableComposerColumns(6, 3)).toBe(1)
}) })

View file

@ -1,46 +1,90 @@
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', () => { 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.name).toBe('Hermes Agent')
expect(DEFAULT_THEME.brand.prompt).toBe('') expect(DEFAULT_THEME.brand.prompt).toBe('')
expect(DEFAULT_THEME.brand.tool).toBe('┊') expect(DEFAULT_THEME.brand.tool).toBe('┊')
}) })
it('has color palette', () => { it('has color palette', async () => {
expect(DEFAULT_THEME.color.gold).toBe('#FFD700') const { DEFAULT_THEME } = await importThemeWithCleanEnv()
expect(DEFAULT_THEME.color.primary).toBe('#FFD700')
expect(DEFAULT_THEME.color.error).toBe('#ef5350') expect(DEFAULT_THEME.color.error).toBe('#ef5350')
}) })
}) })
describe('LIGHT_THEME', () => { describe('LIGHT_THEME', () => {
it('avoids bright-yellow accents unreadable on white backgrounds (#11300)', () => { it('avoids bright-yellow accents unreadable on white backgrounds (#11300)', async () => {
expect(LIGHT_THEME.color.gold).not.toBe('#FFD700') const { LIGHT_THEME } = await importThemeWithCleanEnv()
expect(LIGHT_THEME.color.amber).not.toBe('#FFBF00')
expect(LIGHT_THEME.color.dim).not.toBe('#B8860B') 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') 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(Object.keys(LIGHT_THEME.color).sort()).toEqual(Object.keys(DARK_THEME.color).sort())
expect(LIGHT_THEME.brand).toEqual(DARK_THEME.brand) expect(LIGHT_THEME.brand).toEqual(DARK_THEME.brand)
}) })
}) })
describe('DEFAULT_THEME aliasing', () => { describe('DEFAULT_THEME aliasing', () => {
it('defaults to DARK_THEME when nothing signals light', () => { it('defaults to DARK_THEME when nothing signals light', async () => {
expect(DEFAULT_THEME).toBe(DARK_THEME) const { DEFAULT_THEME, DARK_THEME: DARK } = await importThemeWithCleanEnv()
expect(DEFAULT_THEME).toBe(DARK)
}) })
}) })
describe('detectLightMode', () => { describe('detectLightMode', () => {
it('returns false on empty env', () => { it('returns false on empty env', async () => {
const { detectLightMode } = await importThemeWithCleanEnv()
expect(detectLightMode({})).toBe(false) 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: '1' })).toBe(true)
expect(detectLightMode({ HERMES_TUI_LIGHT: 'true' })).toBe(true) expect(detectLightMode({ HERMES_TUI_LIGHT: 'true' })).toBe(true)
expect(detectLightMode({ HERMES_TUI_LIGHT: 'on' })).toBe(true) expect(detectLightMode({ HERMES_TUI_LIGHT: 'on' })).toBe(true)
@ -48,7 +92,9 @@ describe('detectLightMode', () => {
expect(detectLightMode({ HERMES_TUI_LIGHT: 'off' })).toBe(false) 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;15' })).toBe(true)
expect(detectLightMode({ COLORFGBG: '0;default;15' })).toBe(true) expect(detectLightMode({ COLORFGBG: '0;default;15' })).toBe(true)
expect(detectLightMode({ COLORFGBG: '0;7' })).toBe(true) expect(detectLightMode({ COLORFGBG: '0;7' })).toBe(true)
@ -56,38 +102,136 @@ describe('detectLightMode', () => {
expect(detectLightMode({ COLORFGBG: '7;default;0' })).toBe(false) 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) 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', () => { describe('fromSkin', () => {
it('overrides banner colors', () => { // `fromSkin` closes over DEFAULT_THEME (which is env-derived), so we
expect(fromSkin({ banner_title: '#FF0000' }, {}).color.gold).toBe('#FF0000') // 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', () => { it('preserves unset colors', async () => {
expect(fromSkin({ banner_title: '#FF0000' }, {}).color.amber).toBe(DEFAULT_THEME.color.amber) 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: '$' }) const { brand } = fromSkin({}, { agent_name: 'TestBot', prompt_symbol: '$' })
expect(brand.name).toBe('TestBot') expect(brand.name).toBe('TestBot')
expect(brand.prompt).toBe('$') 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({}, {}).color).toEqual(DEFAULT_THEME.color)
expect(fromSkin({}, {}).brand.icon).toBe(DEFAULT_THEME.brand.icon) 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').bannerLogo).toBe('LOGO')
expect(fromSkin({}, {}, 'LOGO', 'HERO').bannerHero).toBe('HERO') 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' }, {}) const { color } = fromSkin({ ui_ok: '#008000' }, {})
expect(color.ok).toBe('#008000') expect(color.ok).toBe('#008000')
expect(color.statusGood).toBe('#008000') expect(color.statusGood).toBe('#008000')
}) })

View file

@ -1,7 +1,13 @@
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { $uiState, resetUiState } from '../app/uiStore.js' import { $uiState, resetUiState } from '../app/uiStore.js'
import { applyDisplay, normalizeStatusBar } from '../app/useConfigSync.js' import {
applyDisplay,
normalizeBusyInputMode,
normalizeIndicatorStyle,
normalizeMouseTracking,
normalizeStatusBar
} from '../app/useConfigSync.js'
describe('applyDisplay', () => { describe('applyDisplay', () => {
beforeEach(() => { beforeEach(() => {
@ -65,6 +71,19 @@ describe('applyDisplay', () => {
expect(s.sections).toEqual({}) 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', () => { it('parses display.sections into per-section overrides', () => {
const setBell = vi.fn() const setBell = vi.fn()
@ -160,3 +179,116 @@ describe('normalizeStatusBar', () => {
expect(normalizeStatusBar('OFF')).toBe('off') expect(normalizeStatusBar('OFF')).toBe('off')
}) })
}) })
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')
expect(normalizeBusyInputMode('steer')).toBe('steer')
expect(normalizeBusyInputMode('interrupt')).toBe('interrupt')
})
it('trims and lowercases input', () => {
expect(normalizeBusyInputMode(' Queue ')).toBe('queue')
expect(normalizeBusyInputMode('STEER')).toBe('steer')
})
it('defaults to queue for missing/unknown values (TUI-only override)', () => {
// CLI / messaging adapters keep `interrupt` as the framework default
// (see hermes_cli/config.py + tui_gateway/server.py::_load_busy_input_mode);
// the TUI ships `queue` because typing a follow-up while the agent
// streams is the common authoring pattern and an unintended interrupt
// loses work.
expect(normalizeBusyInputMode(undefined)).toBe('queue')
expect(normalizeBusyInputMode(null)).toBe('queue')
expect(normalizeBusyInputMode('')).toBe('queue')
expect(normalizeBusyInputMode('drop')).toBe('queue')
expect(normalizeBusyInputMode(42)).toBe('queue')
})
})
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()
})
it('threads display.busy_input_mode into $uiState', () => {
const setBell = vi.fn()
applyDisplay({ config: { display: { busy_input_mode: 'queue' } } }, setBell)
expect($uiState.get().busyInputMode).toBe('queue')
applyDisplay({ config: { display: { busy_input_mode: 'steer' } } }, setBell)
expect($uiState.get().busyInputMode).toBe('steer')
})
it('falls back to queue when value is missing or invalid (TUI-only default)', () => {
const setBell = vi.fn()
applyDisplay({ config: { display: {} } }, setBell)
expect($uiState.get().busyInputMode).toBe('queue')
applyDisplay({ config: { display: { busy_input_mode: 'drop' } } }, setBell)
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')
})
})

View file

@ -1,6 +1,13 @@
import { STREAM_BATCH_MS } from '../config/timing.js' import { STREAM_BATCH_MS } from '../config/timing.js'
import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js' import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js'
import type { CommandsCatalogResponse, DelegationStatusResponse, GatewayEvent, GatewaySkin } from '../gatewayTypes.js' import type {
CommandsCatalogResponse,
ConfigFullResponse,
DelegationStatusResponse,
GatewayEvent,
GatewaySkin,
SessionMostRecentResponse
} from '../gatewayTypes.js'
import { rpcErrorMessage } from '../lib/rpc.js' import { rpcErrorMessage } from '../lib/rpc.js'
import { topLevelSubagents } from '../lib/subagentTree.js' import { topLevelSubagents } from '../lib/subagentTree.js'
import { formatToolCall, stripAnsi } from '../lib/text.js' import { formatToolCall, stripAnsi } from '../lib/text.js'
@ -171,15 +178,46 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
}) })
.catch((e: unknown) => turnController.pushActivity(`command catalog unavailable: ${rpcErrorMessage(e)}`, 'info')) .catch((e: unknown) => turnController.pushActivity(`command catalog unavailable: ${rpcErrorMessage(e)}`, 'info'))
if (!STARTUP_RESUME_ID) { if (STARTUP_RESUME_ID) {
patchUiState({ status: 'forging session…' }) patchUiState({ status: 'resuming…' })
newSession() resumeById(STARTUP_RESUME_ID)
return return
} }
patchUiState({ status: 'resuming…' }) // Opt-in: when `display.tui_auto_resume_recent` is true, look up
resumeById(STARTUP_RESUME_ID) // the most recent human-facing session and resume it instead of
// forging a brand-new one. Mirrors classic CLI's `hermes -c` /
// `hermes --tui` muscle memory and addresses the audit's "session
// unrecoverable after disconnection" gap. Default off so existing
// users aren't surprised.
rpc<ConfigFullResponse>('config.get', { key: 'full' })
.then(cfg => {
if (!cfg?.config?.display?.tui_auto_resume_recent) {
patchUiState({ status: 'forging session…' })
newSession()
return
}
return rpc<SessionMostRecentResponse>('session.most_recent', {}).then(r => {
const target = r?.session_id
if (target) {
patchUiState({ status: 'resuming most recent…' })
resumeById(target)
return
}
patchUiState({ status: 'forging session…' })
newSession()
})
})
.catch(() => {
patchUiState({ status: 'forging session…' })
newSession()
})
} }
return (ev: GatewayEvent) => { return (ev: GatewayEvent) => {
@ -321,12 +359,30 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
} }
case 'gateway.start_timeout': { case 'gateway.start_timeout': {
const { cwd, python } = ev.payload ?? {} const { cwd, python, stderr_tail: stderrTail } = ev.payload ?? {}
const trace = python || cwd ? ` · ${String(python || '')} ${String(cwd || '')}`.trim() : '' const trace = python || cwd ? ` · ${String(python || '')} ${String(cwd || '')}`.trim() : ''
setStatus('gateway startup timeout') setStatus('gateway startup timeout')
turnController.pushActivity(`gateway startup timed out${trace} · /logs to inspect`, 'error') turnController.pushActivity(`gateway startup timed out${trace} · /logs to inspect`, 'error')
// Surface the most useful stderr lines inline so users can tell
// "wrong python", "missing dep", and "config parse failure"
// apart without leaving the TUI. Filter blank rows BEFORE
// taking the last N so trailing empty lines in the buffer
// don't crowd out actual content; truncate to match the
// 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())
.filter(Boolean)
.slice(-STDERR_LINES_MAX)
for (const line of tailLines) {
turnController.pushActivity(line.slice(0, STDERR_LINE_CAP), 'error')
}
return return
} }

View file

@ -27,11 +27,23 @@ export interface StateSetter<T> {
export type StatusBarMode = 'bottom' | 'off' | 'top' 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 { export interface SelectionApi {
captureScrolledRows: (firstRow: number, lastRow: number, side: 'above' | 'below') => void captureScrolledRows: (firstRow: number, lastRow: number, side: 'above' | 'below') => void
clearSelection: () => void clearSelection: () => void
copySelection: () => Promise<string> copySelection: () => Promise<string>
copySelectionNoClear: () => Promise<string>
getState: () => unknown getState: () => unknown
version: () => number
shiftAnchor: (dRow: number, minRow: number, maxRow: number) => void shiftAnchor: (dRow: number, minRow: number, maxRow: number) => void
shiftSelection: (dRow: number, minRow: number, maxRow: number) => void shiftSelection: (dRow: number, minRow: number, maxRow: number) => void
} }
@ -85,6 +97,7 @@ export interface TranscriptRow {
export interface UiState { export interface UiState {
bgTasks: Set<string> bgTasks: Set<string>
busy: boolean busy: boolean
busyInputMode: BusyInputMode
compact: boolean compact: boolean
detailsMode: DetailsMode detailsMode: DetailsMode
detailsModeCommandOverride: boolean detailsModeCommandOverride: boolean
@ -94,6 +107,7 @@ export interface UiState {
sections: SectionVisibility sections: SectionVisibility
showCost: boolean showCost: boolean
showReasoning: boolean showReasoning: boolean
indicatorStyle: IndicatorStyle
sid: null | string sid: null | string
status: string status: string
statusBar: StatusBarMode statusBar: StatusBarMode

View file

@ -98,13 +98,16 @@ export const opsCommands: SlashCommand[] = [
const action = (rawAction || 'status').toLowerCase() const action = (rawAction || 'status').toLowerCase()
if (!['connect', 'disconnect', 'status'].includes(action)) { if (!['connect', 'disconnect', 'status'].includes(action)) {
return ctx.transcript.sys('usage: /browser [connect|disconnect|status] [url]') return ctx.transcript.sys(
'usage: /browser [connect|disconnect|status] [url] · persistent: set browser.cdp_url in config.yaml'
)
} }
const payload: Record<string, unknown> = { action } const payload: Record<string, unknown> = { action }
const requested = rest.join(' ').trim()
if (action === 'connect') { if (action === 'connect') {
payload.url = rest.join(' ').trim() || 'http://localhost:9222' payload.url = requested || 'http://localhost:9222'
} }
ctx.gateway ctx.gateway
@ -113,14 +116,21 @@ export const opsCommands: SlashCommand[] = [
ctx.guarded<BrowserManageResponse>(r => { ctx.guarded<BrowserManageResponse>(r => {
if (action === 'status') { if (action === 'status') {
return ctx.transcript.sys( return ctx.transcript.sys(
r.connected ? `browser connected: ${r.url || '(url unavailable)'}` : 'browser not connected' r.connected
? `browser connected: ${r.url || '(url unavailable)'}`
: 'browser not connected (try /browser connect <url> or set browser.cdp_url in config.yaml)'
) )
} }
if (action === 'connect') { if (action === 'connect') {
return ctx.transcript.sys( if (r.connected) {
r.connected ? `browser connected: ${r.url || '(url unavailable)'}` : 'browser connect failed' ctx.transcript.sys(`browser connected: ${r.url || '(url unavailable)'}`)
) ctx.transcript.sys('next browser tool call will use this CDP endpoint')
return
}
return ctx.transcript.sys('browser connect failed')
} }
ctx.transcript.sys('browser disconnected') ctx.transcript.sys('browser disconnected')

View file

@ -12,6 +12,7 @@ import type {
} from '../../../gatewayTypes.js' } from '../../../gatewayTypes.js'
import { fmtK } from '../../../lib/text.js' import { fmtK } from '../../../lib/text.js'
import type { PanelSection } from '../../../types.js' import type { PanelSection } from '../../../types.js'
import { DEFAULT_INDICATOR_STYLE, INDICATOR_STYLES, type IndicatorStyle } from '../../interfaces.js'
import { patchOverlayState } from '../../overlayStore.js' import { patchOverlayState } from '../../overlayStore.js'
import { patchUiState } from '../../uiStore.js' import { patchUiState } from '../../uiStore.js'
import type { SlashCommand } from '../types.js' import type { SlashCommand } from '../types.js'
@ -268,6 +269,45 @@ 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)', help: 'toggle yolo mode (per-session approvals)',
name: 'yolo', name: 'yolo',

View file

@ -316,6 +316,10 @@ class TurnController {
} }
recordTodos(value: unknown) { recordTodos(value: unknown) {
if (this.interrupted) {
return
}
const todos = parseTodos(value) const todos = parseTodos(value)
if (todos !== null) { if (todos !== null) {
@ -397,6 +401,10 @@ class TurnController {
} }
pushTrail(line: string) { pushTrail(line: string) {
if (this.interrupted) {
return
}
patchTurnState(state => { patchTurnState(state => {
if (state.turnTrail.at(-1) === line) { if (state.turnTrail.at(-1) === line) {
return state return state
@ -423,7 +431,13 @@ class TurnController {
recordMessageComplete(payload: { rendered?: string; reasoning?: string; text?: string }) { recordMessageComplete(payload: { rendered?: string; reasoning?: string; text?: string }) {
this.closeReasoningSegment() this.closeReasoningSegment()
const rawText = (payload.rendered ?? payload.text ?? this.bufRef).trimStart() // Ink renders markdown via <Md>; the gateway's Rich-rendered ANSI
// (`payload.rendered`) is for terminals that can't. Prioritising
// `rendered` here garbles output whenever a user opts into
// `display.final_response_markdown: render` because raw ANSI escapes
// pass through into the React tree. Prefer raw text and fall back
// only when the gateway elected not to send any (#16391).
const rawText = (payload.text ?? payload.rendered ?? this.bufRef).trimStart()
const split = splitReasoning(rawText) const split = splitReasoning(rawText)
const finalText = finalTail(split.text, this.segmentMessages) const finalText = finalTail(split.text, this.segmentMessages)
const existingReasoning = this.reasoningText.trim() || String(payload.reasoning ?? '').trim() const existingReasoning = this.reasoningText.trim() || String(payload.reasoning ?? '').trim()
@ -508,15 +522,20 @@ class TurnController {
return { finalMessages, finalText, wasInterrupted } return { finalMessages, finalText, wasInterrupted }
} }
recordMessageDelta({ rendered, text }: { rendered?: string; text?: string }) { recordMessageDelta({ text }: { rendered?: string; text?: string }) {
this.pruneTransient() if (this.interrupted || !text) {
this.endReasoningPhase()
if (!text || this.interrupted) {
return return
} }
this.bufRef = rendered ?? this.bufRef + text this.pruneTransient()
this.endReasoningPhase()
// Always accumulate the raw text delta. The pre-#16391 path replaced
// the entire buffer with `rendered` (an *incremental* Rich ANSI
// fragment), which on every tick discarded everything streamed so far
// — visible as overlapping coloured text and lost prose under
// `display.final_response_markdown: render`.
this.bufRef += text
if (getUiState().streaming) { if (getUiState().streaming) {
this.scheduleStreaming() this.scheduleStreaming()
@ -524,7 +543,7 @@ class TurnController {
} }
recordReasoningAvailable(text: string) { recordReasoningAvailable(text: string) {
if (!getUiState().showReasoning) { if (this.interrupted || !getUiState().showReasoning) {
return return
} }
@ -542,7 +561,7 @@ class TurnController {
} }
recordReasoningDelta(text: string) { recordReasoningDelta(text: string) {
if (!getUiState().showReasoning) { if (this.interrupted || !getUiState().showReasoning) {
return return
} }
@ -570,6 +589,10 @@ class TurnController {
duration?: number, duration?: number,
todos?: unknown todos?: unknown
) { ) {
if (this.interrupted) {
return
}
this.recordTodos(todos) this.recordTodos(todos)
const line = this.completeTool(toolId, fallbackName, error, summary, duration) const line = this.completeTool(toolId, fallbackName, error, summary, duration)
@ -585,6 +608,10 @@ class TurnController {
error?: string, error?: string,
duration?: number duration?: number
) { ) {
if (this.interrupted) {
return
}
this.flushStreamingSegment() this.flushStreamingSegment()
this.pushInlineDiffSegment(diffText, [this.completeTool(toolId, fallbackName, error, '', duration)]) this.pushInlineDiffSegment(diffText, [this.completeTool(toolId, fallbackName, error, '', duration)])
this.publishToolState() this.publishToolState()
@ -626,6 +653,10 @@ class TurnController {
} }
recordToolProgress(toolName: string, preview: string) { recordToolProgress(toolName: string, preview: string) {
if (this.interrupted) {
return
}
const index = this.activeTools.findIndex(tool => tool.name === toolName) const index = this.activeTools.findIndex(tool => tool.name === toolName)
if (index < 0) { if (index < 0) {
@ -645,6 +676,10 @@ class TurnController {
} }
recordToolStart(toolId: string, name: string, context: string) { recordToolStart(toolId: string, name: string, context: string) {
if (this.interrupted) {
return
}
this.flushStreamingSegment() this.flushStreamingSegment()
this.closeReasoningSegment() this.closeReasoningSegment()
this.pruneTransient() this.pruneTransient()
@ -716,6 +751,7 @@ class TurnController {
this.reasoningSegmentIndex = null this.reasoningSegmentIndex = null
this.turnTools = [] this.turnTools = []
this.toolTokenAcc = 0 this.toolTokenAcc = 0
this.interrupted = false
this.persistedToolLabels.clear() this.persistedToolLabels.clear()
patchUiState({ busy: true }) patchUiState({ busy: true })
patchTurnState({ activity: [], outcome: '', subagents: [], toolTokens: 0, tools: [], turnTrail: [] }) patchTurnState({ activity: [], outcome: '', subagents: [], toolTokens: 0, tools: [], turnTrail: [] })

View file

@ -4,14 +4,16 @@ import { MOUSE_TRACKING } from '../config/env.js'
import { ZERO } from '../domain/usage.js' import { ZERO } from '../domain/usage.js'
import { DEFAULT_THEME } from '../theme.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 => ({ const buildUiState = (): UiState => ({
bgTasks: new Set(), bgTasks: new Set(),
busy: false, busy: false,
busyInputMode: 'queue',
compact: false, compact: false,
detailsMode: 'collapsed', detailsMode: 'collapsed',
detailsModeCommandOverride: false, detailsModeCommandOverride: false,
indicatorStyle: DEFAULT_INDICATOR_STYLE,
info: null, info: null,
inlineDiffs: true, inlineDiffs: true,
mouseTracking: MOUSE_TRACKING, mouseTracking: MOUSE_TRACKING,

View file

@ -10,7 +10,13 @@ import type {
} from '../gatewayTypes.js' } from '../gatewayTypes.js'
import { asRpcResult } from '../lib/rpc.js' import { asRpcResult } from '../lib/rpc.js'
import type { StatusBarMode } from './interfaces.js' import {
DEFAULT_INDICATOR_STYLE,
INDICATOR_STYLES,
type BusyInputMode,
type IndicatorStyle,
type StatusBarMode,
} from './interfaces.js'
import { turnController } from './turnController.js' import { turnController } from './turnController.js'
import { patchUiState } from './uiStore.js' import { patchUiState } from './uiStore.js'
@ -24,6 +30,52 @@ const STATUSBAR_ALIAS: Record<string, StatusBarMode> = {
export const normalizeStatusBar = (raw: unknown): StatusBarMode => export const normalizeStatusBar = (raw: unknown): StatusBarMode =>
raw === false ? 'off' : typeof raw === 'string' ? (STATUSBAR_ALIAS[raw.trim().toLowerCase()] ?? 'top') : 'top' raw === false ? 'off' : typeof raw === 'string' ? (STATUSBAR_ALIAS[raw.trim().toLowerCase()] ?? 'top') : 'top'
const BUSY_MODES = new Set<BusyInputMode>(['interrupt', 'queue', 'steer'])
// TUI defaults to `queue` even though the framework default
// (`hermes_cli/config.py`) is `interrupt`. Rationale: in a full-screen
// TUI you're typically authoring the next prompt while the agent is
// still streaming, and an unintended interrupt loses work. Set
// `display.busy_input_mode: interrupt` (or `steer`) explicitly to
// opt out per-config; CLI / messaging adapters keep their `interrupt`
// default unchanged.
const TUI_BUSY_DEFAULT: BusyInputMode = 'queue'
export const normalizeBusyInputMode = (raw: unknown): BusyInputMode => {
if (typeof raw !== 'string') {
return TUI_BUSY_DEFAULT
}
const v = raw.trim().toLowerCase() as 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 MTIME_POLL_MS = 5000
const quietRpc = async <T extends Record<string, any> = Record<string, any>>( const quietRpc = async <T extends Record<string, any> = Record<string, any>>(
@ -43,11 +95,13 @@ export const applyDisplay = (cfg: ConfigFullResponse | null, setBell: (v: boolea
setBell(!!d.bell_on_complete) setBell(!!d.bell_on_complete)
patchUiState({ patchUiState({
busyInputMode: normalizeBusyInputMode(d.busy_input_mode),
compact: !!d.tui_compact, compact: !!d.tui_compact,
detailsMode: resolveDetailsMode(d), detailsMode: resolveDetailsMode(d),
detailsModeCommandOverride: false, detailsModeCommandOverride: false,
indicatorStyle: normalizeIndicatorStyle(d.tui_status_indicator),
inlineDiffs: d.inline_diffs !== false, inlineDiffs: d.inline_diffs !== false,
mouseTracking: d.tui_mouse !== false, mouseTracking: normalizeMouseTracking(d),
sections: resolveSections(d.sections), sections: resolveSections(d.sections),
showCost: !!d.show_cost, showCost: !!d.show_cost,
showReasoning: !!d.show_reasoning, showReasoning: !!d.show_reasoning,

View file

@ -17,6 +17,7 @@ import type {
import { useGitBranch } from '../hooks/useGitBranch.js' import { useGitBranch } from '../hooks/useGitBranch.js'
import { useVirtualHistory } from '../hooks/useVirtualHistory.js' import { useVirtualHistory } from '../hooks/useVirtualHistory.js'
import { appendTranscriptMessage } from '../lib/messages.js' import { appendTranscriptMessage } from '../lib/messages.js'
import { isMac } from '../lib/platform.js'
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
import { terminalParityHints } from '../lib/terminalParity.js' import { terminalParityHints } from '../lib/terminalParity.js'
import { buildToolTrailLine, sameToolTrailGroup, toolTrailLabel } from '../lib/text.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) 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') { if (status === 'ready') {
return t.ok return t.ok
} }
@ -65,7 +66,7 @@ const statusColorOf = (status: string, t: { dim: string; error: string; ok: stri
return t.warn return t.warn
} }
return t.dim return t.muted
} }
export function useMainApp(gw: GatewayClient) { export function useMainApp(gw: GatewayClient) {
@ -143,11 +144,47 @@ export function useMainApp(gw: GatewayClient) {
const hasSelection = useHasSelection() const hasSelection = useHasSelection()
const selection = useSelection() const selection = useSelection()
const lastCopiedVersionRef = useRef(-1)
useEffect(() => { useEffect(() => {
selection.setSelectionBgColor(ui.theme.color.selectionBg) selection.setSelectionBgColor(ui.theme.color.selectionBg)
}, [selection, 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(() => { const clearSelection = useCallback(() => {
selection.clearSelection() selection.clearSelection()
getInputSelection()?.collapseToEnd() getInputSelection()?.collapseToEnd()

View file

@ -4,7 +4,12 @@ import { TYPING_IDLE_MS } from '../config/timing.js'
import { attachedImageNotice } from '../domain/messages.js' import { attachedImageNotice } from '../domain/messages.js'
import { looksLikeSlashCommand } from '../domain/slash.js' import { looksLikeSlashCommand } from '../domain/slash.js'
import type { GatewayClient } from '../gatewayClient.js' import type { GatewayClient } from '../gatewayClient.js'
import type { InputDetectDropResponse, PromptSubmitResponse, ShellExecResponse } from '../gatewayTypes.js' import type {
InputDetectDropResponse,
PromptSubmitResponse,
SessionSteerResponse,
ShellExecResponse
} from '../gatewayTypes.js'
import { asRpcResult } from '../lib/rpc.js' import { asRpcResult } from '../lib/rpc.js'
import { hasInterpolation, INTERPOLATION_RE } from '../protocol/interpolation.js' import { hasInterpolation, INTERPOLATION_RE } from '../protocol/interpolation.js'
import { PASTE_SNIPPET_RE } from '../protocol/paste.js' import { PASTE_SNIPPET_RE } from '../protocol/paste.js'
@ -207,6 +212,70 @@ export function useSubmission(opts: UseSubmissionOptions) {
[interpolate, send, shellExec] [interpolate, send, shellExec]
) )
// Honors `display.busy_input_mode` from config.yaml (CLI parity):
// - 'queue' (legacy): append to queueRef; drains on busy → false
// - 'steer' : inject into the current turn via session.steer; falls
// back to queue when steer is rejected (no agent / no
// tool window).
// - 'interrupt' (default): cancel the in-flight turn, then send the
// new text as a fresh prompt so it actually moves.
//
// `opts.fallbackToFront` controls whether a steer fallback re-inserts
// at the front of the queue (used by the queue-edit path to preserve
// a picked item's position); the mainline submit path always appends.
const handleBusyInput = useCallback(
(full: string, opts: { fallbackToFront?: boolean } = {}) => {
const live = getUiState()
const mode = live.busyInputMode
const fallback = (note: string) => {
if (opts.fallbackToFront) {
composerRefs.queueRef.current.unshift(full)
composerActions.syncQueue()
} else {
composerActions.enqueue(full)
}
sys(note)
}
if (mode === 'queue') {
return composerActions.enqueue(full)
}
if (mode === 'steer' && live.sid) {
gw.request<SessionSteerResponse>('session.steer', { session_id: live.sid, text: full })
.then(raw => {
const r = asRpcResult<SessionSteerResponse>(raw)
if (r?.status !== 'queued') {
fallback('steer rejected — message queued for next turn')
}
})
.catch(() => fallback('steer failed — message queued for next turn'))
return
}
// 'interrupt' (default): tear down the current turn, then send.
// `interruptTurn` fires `session.interrupt` without awaiting; if
// the gateway is still mid-response when `prompt.submit` lands,
// `send()`'s catch path re-queues with a "queued: ..." sys note
// (`isSessionBusyError`) — so a lost race degrades to queue
// semantics, not a dropped message.
if (live.sid) {
turnController.interruptTurn({ appendMessage, gw, sid: live.sid, sys })
}
if (hasInterpolation(full)) {
patchUiState({ busy: true })
return interpolate(full, send)
}
send(full)
},
[appendMessage, composerActions, composerRefs, gw, interpolate, send, sys]
)
const dispatchSubmission = useCallback( const dispatchSubmission = useCallback(
(full: string) => { (full: string) => {
if (!full.trim()) { if (!full.trim()) {
@ -252,9 +321,16 @@ export function useSubmission(opts: UseSubmissionOptions) {
} }
if (getUiState().busy) { if (getUiState().busy) {
composerRefs.queueRef.current.unshift(picked) // 'interrupt' / 'steer' should reach the live turn instead of
// silently going back to the queue. handleBusyInput resolves
// mode-specific behavior (interrupt-and-send, steer, or queue).
if (getUiState().busyInputMode === 'queue') {
composerRefs.queueRef.current.unshift(picked)
return composerActions.syncQueue() return composerActions.syncQueue()
}
return handleBusyInput(picked, { fallbackToFront: true })
} }
return sendQueued(picked) return sendQueued(picked)
@ -263,7 +339,7 @@ export function useSubmission(opts: UseSubmissionOptions) {
composerActions.pushHistory(full) composerActions.pushHistory(full)
if (getUiState().busy) { if (getUiState().busy) {
return composerActions.enqueue(full) return handleBusyInput(full)
} }
if (hasInterpolation(full)) { if (hasInterpolation(full)) {
@ -274,7 +350,17 @@ export function useSubmission(opts: UseSubmissionOptions) {
send(full) send(full)
}, },
[appendMessage, composerActions, composerRefs, interpolate, send, sendQueued, shellExec, slashRef] [
appendMessage,
composerActions,
composerRefs,
handleBusyInput,
interpolate,
send,
sendQueued,
shellExec,
slashRef
]
) )
const submit = useCallback( const submit = useCallback(

View file

@ -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 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 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 export const LOGO_WIDTH = 98

View file

@ -79,15 +79,15 @@ const FILTER_PREDICATES: Record<FilterMode, (n: SubagentNode) => boolean> = {
} }
const STATUS_GLYPH: Record<Status, { color: (t: Theme) => string; glyph: string }> = { const STATUS_GLYPH: Record<Status, { color: (t: Theme) => string; glyph: string }> = {
running: { color: t => t.color.amber, glyph: '●' }, running: { color: t => t.color.accent, glyph: '●' },
queued: { color: t => t.color.dim, glyph: '○' }, queued: { color: t => t.color.muted, glyph: '○' },
completed: { color: t => t.color.statusGood, glyph: '✓' }, completed: { color: t => t.color.statusGood, glyph: '✓' },
interrupted: { color: t => t.color.warn, glyph: '■' }, interrupted: { color: t => t.color.warn, glyph: '■' },
failed: { color: t => t.color.error, glyph: '✗' } failed: { color: t => t.color.error, glyph: '✗' }
} }
// Heatmap palette — cold → hot, resolved against the active theme. // 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 ───────────────────────────────────────────────────── // ── Pure helpers ─────────────────────────────────────────────────────
@ -160,8 +160,8 @@ function OverlayScrollbar({
const vBar = (n: number) => (n > 0 ? `${'│\n'.repeat(n - 1)}` : '') const vBar = (n: number) => (n > 0 ? `${'│\n'.repeat(n - 1)}` : '')
const thumbBody = `${'┃\n'.repeat(Math.max(0, thumb - 1))}` const thumbBody = `${'┃\n'.repeat(Math.max(0, thumb - 1))}`
const thumbColor = grab !== null ? t.color.gold : t.color.amber const thumbColor = grab !== null ? t.color.primary : t.color.accent
const trackColor = hover ? t.color.bronze : t.color.dim const trackColor = hover ? t.color.border : t.color.muted
const jump = (row: number, offset: number) => { const jump = (row: number, offset: number) => {
if (!s || !scrollable) { if (!s || !scrollable) {
@ -301,7 +301,7 @@ function GanttStrip({
return ( return (
<Box flexDirection="column" marginBottom={1}> <Box flexDirection="column" marginBottom={1}>
<Text color={t.color.dim}> <Text color={t.color.muted}>
Timeline · {fmtElapsedLabel(Math.max(0, totalSeconds))} Timeline · {fmtElapsedLabel(Math.max(0, totalSeconds))}
{windowLabel} {windowLabel}
</Text> </Text>
@ -309,7 +309,7 @@ function GanttStrip({
{shown.map(({ endAt, idx, node, startAt }) => { {shown.map(({ endAt, idx, node, startAt }) => {
const active = idx === cursor const active = idx === cursor
const { color } = statusGlyph(node.item, t) 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 elSec = displayElapsedSeconds(node.item, now)
const elLabel = elSec != null ? fmtElapsedLabel(elSec) : '' const elLabel = elSec != null ? fmtElapsedLabel(elSec) : ''
@ -321,7 +321,7 @@ function GanttStrip({
{' '} {' '}
</Text> </Text>
<Text color={active ? t.color.amber : color}>{bar(startAt, endAt)}</Text> <Text color={active ? t.color.accent : color}>{bar(startAt, endAt)}</Text>
{elLabel ? ( {elLabel ? (
<Text color={accent}> <Text color={accent}>
@ -333,13 +333,13 @@ function GanttStrip({
) )
})} })}
<Text color={t.color.dim} dim> <Text color={t.color.muted} dim>
{' '} {' '}
{ruler} {ruler}
</Text> </Text>
{totalSeconds > 0 ? ( {totalSeconds > 0 ? (
<Text color={t.color.dim} dim> <Text color={t.color.muted} dim>
{' '} {' '}
{rulerLabels} {rulerLabels}
</Text> </Text>
@ -368,7 +368,7 @@ function OverlaySection({
<Box flexDirection="column" marginTop={1}> <Box flexDirection="column" marginTop={1}>
<Box onClick={() => toggleOverlaySection(title, defaultOpen)}> <Box onClick={() => toggleOverlaySection(title, defaultOpen)}>
<Text color={t.color.label}> <Text color={t.color.label}>
<Text color={t.color.amber}>{open ? '▾ ' : '▸ '}</Text> <Text color={t.color.accent}>{open ? '▾ ' : '▸ '}</Text>
{title} {title}
{typeof count === 'number' ? ` (${count})` : ''} {typeof count === 'number' ? ` (${count})` : ''}
</Text> </Text>
@ -383,7 +383,7 @@ function Field({ name, t, value }: { name: string; t: Theme; value: ReactNode })
return ( return (
<Text wrap="truncate-end"> <Text wrap="truncate-end">
<Text color={t.color.label}>{name} · </Text> <Text color={t.color.label}>{name} · </Text>
<Text color={t.color.cornsilk}>{value}</Text> <Text color={t.color.text}>{value}</Text>
</Text> </Text>
) )
} }
@ -411,8 +411,8 @@ function Detail({ id, node, t }: { id?: string; node: SubagentNode; t: Theme })
return ( return (
<Box flexDirection="column"> <Box flexDirection="column">
<Text bold color={t.color.cornsilk} wrap="wrap"> <Text bold color={t.color.text} wrap="wrap">
{id ? <Text color={t.color.amber}>#{id} </Text> : null} {id ? <Text color={t.color.accent}>#{id} </Text> : null}
<Text color={color}>{glyph}</Text> {item.goal} <Text color={color}>{glyph}</Text> {item.goal}
</Text> </Text>
@ -472,20 +472,20 @@ function Detail({ id, node, t }: { id?: string; node: SubagentNode; t: Theme })
))} ))}
{filesRead.slice(0, 8).map((p, i) => ( {filesRead.slice(0, 8).map((p, i) => (
<Text color={t.color.cornsilk} key={`r-${i}`} wrap="truncate-end"> <Text color={t.color.text} key={`r-${i}`} wrap="truncate-end">
<Text color={t.color.dim}>·</Text> {p} <Text color={t.color.muted}>·</Text> {p}
</Text> </Text>
))} ))}
{filesOverflow > 0 ? <Text color={t.color.dim}>+{filesOverflow} more</Text> : null} {filesOverflow > 0 ? <Text color={t.color.muted}>+{filesOverflow} more</Text> : null}
</OverlaySection> </OverlaySection>
) : null} ) : null}
{toolLines.length > 0 ? ( {toolLines.length > 0 ? (
<OverlaySection count={toolLines.length} defaultOpen t={t} title="Tool calls"> <OverlaySection count={toolLines.length} defaultOpen t={t} title="Tool calls">
{toolLines.map((line, i) => ( {toolLines.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.dim}>·</Text> {line} <Text color={t.color.muted}>·</Text> {line}
</Text> </Text>
))} ))}
</OverlaySection> </OverlaySection>
@ -494,8 +494,8 @@ function Detail({ id, node, t }: { id?: string; node: SubagentNode; t: Theme })
{outputTail.length > 0 ? ( {outputTail.length > 0 ? (
<OverlaySection count={outputTail.length} defaultOpen t={t} title="Output"> <OverlaySection count={outputTail.length} defaultOpen t={t} title="Output">
{outputTail.map((entry, i) => ( {outputTail.map((entry, i) => (
<Text color={entry.isError ? t.color.error : t.color.cornsilk} key={i} wrap="wrap"> <Text color={entry.isError ? t.color.error : t.color.text} key={i} wrap="wrap">
<Text bold color={entry.isError ? t.color.error : t.color.amber}> <Text bold color={entry.isError ? t.color.error : t.color.accent}>
{entry.tool} {entry.tool}
</Text>{' '} </Text>{' '}
{entry.preview} {entry.preview}
@ -507,7 +507,7 @@ function Detail({ id, node, t }: { id?: string; node: SubagentNode; t: Theme })
{item.notes.length ? ( {item.notes.length ? (
<OverlaySection count={item.notes.length} t={t} title="Progress"> <OverlaySection count={item.notes.length} t={t} title="Progress">
{item.notes.slice(-6).map((line, i) => ( {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 color={t.color.label}>·</Text> {line}
</Text> </Text>
))} ))}
@ -516,7 +516,7 @@ function Detail({ id, node, t }: { id?: string; node: SubagentNode; t: Theme })
{item.summary ? ( {item.summary ? (
<OverlaySection defaultOpen t={t} title="Summary"> <OverlaySection defaultOpen t={t} title="Summary">
<Text color={t.color.cornsilk} wrap="wrap"> <Text color={t.color.text} wrap="wrap">
{item.summary} {item.summary}
</Text> </Text>
</OverlaySection> </OverlaySection>
@ -552,16 +552,16 @@ function ListRow({
const paren = line ? line.indexOf('(') : -1 const paren = line ? line.indexOf('(') : -1
const toolShort = line ? (paren > 0 ? line.slice(0, paren) : line).trim() : '' const toolShort = line ? (paren > 0 ? line.slice(0, paren) : line).trim() : ''
const trailing = toolShort ? ` · ${compactPreview(toolShort, 14)}` : '' 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 ( return (
<Text bold={active} color={fg} inverse={active} wrap="truncate-end"> <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)} {indentFor(node.item.depth)}
{heatMarker ? <Text color={heatMarker}></Text> : null} {heatMarker ? <Text color={heatMarker}></Text> : null}
<Text color={active ? fg : color}>{glyph}</Text> {goal} <Text color={active ? fg : color}>{glyph}</Text> {goal}
<Text color={active ? fg : t.color.dim}> <Text color={active ? fg : t.color.muted}>
{toolsCount} {toolsCount}
{kids} {kids}
{trailing} {trailing}
@ -585,16 +585,16 @@ function DiffPane({
}) { }) {
return ( return (
<Box flexDirection="column" width={width}> <Box flexDirection="column" width={width}>
<Text bold color={t.color.cornsilk}> <Text bold color={t.color.text}>
{label} {label}
</Text> </Text>
<Text color={t.color.dim} wrap="truncate-end"> <Text color={t.color.muted} wrap="truncate-end">
{snapshot.label} {snapshot.label}
</Text> </Text>
<Box marginTop={1}> <Box marginTop={1}>
<Text color={t.color.dim} wrap="truncate-end"> <Text color={t.color.muted} wrap="truncate-end">
{formatSummary(totals)} {formatSummary(totals)}
</Text> </Text>
</Box> </Box>
@ -606,7 +606,7 @@ function DiffPane({
const { color, glyph } = statusGlyph(s, t) const { color, glyph } = statusGlyph(s, t)
return ( 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 color={color}>{glyph}</Text> {s.goal || 'subagent'}
</Text> </Text>
) )
@ -644,10 +644,10 @@ function DiffView({
return ( return (
<Box flexDirection="column" flexGrow={1} paddingX={1} paddingY={1}> <Box flexDirection="column" flexGrow={1} paddingX={1} paddingY={1}>
<Box flexDirection="column" marginBottom={1}> <Box flexDirection="column" marginBottom={1}>
<Text bold color={t.color.bronze}> <Text bold color={t.color.border}>
Replay diff Replay diff
</Text> </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>
<Box flexDirection="row" marginBottom={1}> <Box flexDirection="row" marginBottom={1}>
@ -657,24 +657,24 @@ function DiffView({
</Box> </Box>
<Box flexDirection="column" marginTop={1}> <Box flexDirection="column" marginTop={1}>
<Text bold color={t.color.amber}> <Text bold color={t.color.accent}>
Δ Δ
</Text> </Text>
<Text color={t.color.cornsilk}> <Text color={t.color.text}>
{diffMetricLine('agents', aTotals.descendantCount, bTotals.descendantCount, round)} {diffMetricLine('agents', aTotals.descendantCount, bTotals.descendantCount, round)}
</Text> </Text>
<Text color={t.color.cornsilk}>{diffMetricLine('tools', aTotals.totalTools, bTotals.totalTools, round)}</Text> <Text color={t.color.text}>{diffMetricLine('tools', aTotals.totalTools, bTotals.totalTools, round)}</Text>
<Text color={t.color.cornsilk}> <Text color={t.color.text}>
{diffMetricLine('depth', aTotals.maxDepthFromHere, bTotals.maxDepthFromHere, round)} {diffMetricLine('depth', aTotals.maxDepthFromHere, bTotals.maxDepthFromHere, round)}
</Text> </Text>
<Text color={t.color.cornsilk}> <Text color={t.color.text}>
{diffMetricLine('duration', aTotals.totalDuration, bTotals.totalDuration, n => `${n.toFixed(1)}s`)} {diffMetricLine('duration', aTotals.totalDuration, bTotals.totalDuration, n => `${n.toFixed(1)}s`)}
</Text> </Text>
<Text color={t.color.cornsilk}> <Text color={t.color.text}>
{diffMetricLine('tokens', sumTokens(aTotals), sumTokens(bTotals), fmtTokens)} {diffMetricLine('tokens', sumTokens(aTotals), sumTokens(bTotals), fmtTokens)}
</Text> </Text>
<Text color={t.color.cornsilk}>{diffMetricLine('cost', aTotals.costUsd, bTotals.costUsd, dollars)}</Text> <Text color={t.color.text}>{diffMetricLine('cost', aTotals.costUsd, bTotals.costUsd, dollars)}</Text>
</Box> </Box>
</Box> </Box>
) )
@ -985,11 +985,11 @@ export function AgentsOverlay({ gw, initialHistoryIndex = 0, onClose, t }: Agent
<Box alignItems="stretch" flexDirection="column" flexGrow={1} paddingX={1} paddingY={1}> <Box alignItems="stretch" flexDirection="column" flexGrow={1} paddingX={1} paddingY={1}>
<Box flexDirection="column" marginBottom={1}> <Box flexDirection="column" marginBottom={1}>
<Text wrap="truncate-end"> <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} {title}
</Text> </Text>
{metaLine ? ( {metaLine ? (
<Text color={t.color.dim}> <Text color={t.color.muted}>
{' '} {' '}
{metaLine} {metaLine}
</Text> </Text>
@ -999,7 +999,7 @@ export function AgentsOverlay({ gw, initialHistoryIndex = 0, onClose, t }: Agent
{rows.length === 0 ? ( {rows.length === 0 ? (
<Box flexDirection="column" flexGrow={1}> <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> </Box>
) : mode === 'list' ? ( ) : mode === 'list' ? (
<Box flexDirection="column" flexGrow={1} flexShrink={1} minHeight={0}> <Box flexDirection="column" flexGrow={1} flexShrink={1} minHeight={0}>
@ -1034,17 +1034,17 @@ export function AgentsOverlay({ gw, initialHistoryIndex = 0, onClose, t }: Agent
)} )}
<Box flexDirection="column" marginTop={1}> <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' ? ( {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: /jk move · g/G top/bottom · Enter/ open detail{controlsHint} · s sort:{SORT_LABEL[sort]} · f filter:
{FILTER_LABEL[filter]} {FILTER_LABEL[filter]}
{history.length > 0 ? ` · [ / ] history ${historyIndex}/${history.length}` : ''} {history.length > 0 ? ` · [ / ] history ${historyIndex}/${history.length}` : ''}
{' · q close'} {' · q close'}
</Text> </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 /jk scroll · PgUp/PgDn page · g/G top/bottom · Esc/ back to list{controlsHint} · q close
</Text> </Text>
)} )}

View file

@ -1,8 +1,11 @@
import { Box, type ScrollBoxHandle, Text } from '@hermes/ink' import { Box, type ScrollBoxHandle, Text } from '@hermes/ink'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import { type ReactNode, type RefObject, useEffect, useMemo, useState } from 'react' import { type ReactNode, type RefObject, useEffect, useMemo, useState } from 'react'
import unicodeSpinners from 'unicode-animations'
import { $delegationState } from '../app/delegationStore.js' import { $delegationState } from '../app/delegationStore.js'
import type { IndicatorStyle } from '../app/interfaces.js'
import { $uiState } from '../app/uiStore.js'
import { useTurnSelector } from '../app/turnStore.js' import { useTurnSelector } from '../app/turnStore.js'
import { FACES } from '../content/faces.js' import { FACES } from '../content/faces.js'
import { VERBS } from '../content/verbs.js' import { VERBS } from '../content/verbs.js'
@ -17,30 +20,103 @@ import type { Msg, Usage } from '../types.js'
const FACE_TICK_MS = 2500 const FACE_TICK_MS = 2500
const HEART_COLORS = ['#ff5fa2', '#ff4d6d'] 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 }) { 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 [tick, setTick] = useState(() => Math.floor(Math.random() * 1000))
const [verbTick, setVerbTick] = useState(() => Math.floor(Math.random() * VERBS.length))
const [now, setNow] = useState(() => Date.now()) 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(() => { 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) 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 () => { return () => {
clearInterval(face) clearInterval(glyph)
clearInterval(clock) 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 ( return (
<Text color={color}> <Text color={color}>
{FACES[tick % FACES.length]} {VERBS[tick % VERBS.length]}{startedAt ? ` · ${fmtDuration(now - startedAt)}` : ''} {frame}
{verbSegment}
{durationSegment}
</Text> </Text>
) )
} }
function ctxBarColor(pct: number | undefined, t: Theme) { function ctxBarColor(pct: number | undefined, t: Theme) {
if (pct == null) { if (pct == null) {
return t.color.dim return t.color.muted
} }
if (pct >= 95) { if (pct >= 95) {
@ -93,7 +169,7 @@ function SpawnHud({ t }: { t: Theme }) {
const concRatio = maxConc ? widestLevel / maxConc : 0 const concRatio = maxConc ? widestLevel / maxConc : 0
const ratio = Math.max(depthRatio, concRatio) 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[] = [] const pieces: string[] = []
@ -162,21 +238,21 @@ const modelLabel = (model: string, effort?: string, fast?: boolean) =>
export function GoodVibesHeart({ tick, t }: { tick: number; t: Theme }) { export function GoodVibesHeart({ tick, t }: { tick: number; t: Theme }) {
const [active, setActive] = useState(false) const [active, setActive] = useState(false)
const [color, setColor] = useState(t.color.amber) const [color, setColor] = useState(t.color.accent)
useEffect(() => { useEffect(() => {
if (tick <= 0) { if (tick <= 0) {
return 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)]!) setColor(palette[Math.floor(Math.random() * palette.length)]!)
setActive(true) setActive(true)
const id = setTimeout(() => setActive(false), 650) const id = setTimeout(() => setActive(false), 650)
return () => clearTimeout(id) return () => clearTimeout(id)
}, [t.color.amber, tick]) }, [t.color.accent, tick])
if (!active) { if (!active) {
return null return null
@ -217,23 +293,23 @@ export function StatusRule({
return ( return (
<Box height={1}> <Box height={1}>
<Box flexShrink={1} width={leftWidth}> <Box flexShrink={1} width={leftWidth}>
<Text color={t.color.bronze} wrap="truncate-end"> <Text color={t.color.border} wrap="truncate-end">
{'─ '} {'─ '}
{busy ? ( {busy ? (
<FaceTicker color={statusColor} startedAt={turnStartedAt} /> <FaceTicker color={statusColor} startedAt={turnStartedAt} />
) : ( ) : (
<Text color={statusColor}>{status}</Text> <Text color={statusColor}>{status}</Text>
)} )}
<Text color={t.color.dim}> {modelLabel(model, modelReasoningEffort, modelFast)}</Text> <Text color={t.color.muted}> {modelLabel(model, modelReasoningEffort, modelFast)}</Text>
{ctxLabel ? <Text color={t.color.dim}> {ctxLabel}</Text> : null} {ctxLabel ? <Text color={t.color.muted}> {ctxLabel}</Text> : null}
{bar ? ( {bar ? (
<Text color={t.color.dim}> <Text color={t.color.muted}>
{' │ '} {' │ '}
<Text color={barColor}>[{bar}]</Text> <Text color={barColor}>{pct != null ? `${pct}%` : ''}</Text> <Text color={barColor}>[{bar}]</Text> <Text color={barColor}>{pct != null ? `${pct}%` : ''}</Text>
</Text> </Text>
) : null} ) : null}
{sessionStartedAt ? ( {sessionStartedAt ? (
<Text color={t.color.dim}> <Text color={t.color.muted}>
{' │ '} {' │ '}
<SessionDuration startedAt={sessionStartedAt} /> <SessionDuration startedAt={sessionStartedAt} />
</Text> </Text>
@ -242,21 +318,21 @@ export function StatusRule({
{voiceLabel ? ( {voiceLabel ? (
<Text <Text
color={ 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} {voiceLabel}
</Text> </Text>
) : null} ) : 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' ? ( {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} ) : null}
</Text> </Text>
</Box> </Box>
<Text color={t.color.bronze}> </Text> <Text color={t.color.border}> </Text>
<Text color={t.color.label}>{cwdLabel}</Text> <Text color={t.color.label}>{cwdLabel}</Text>
</Box> </Box>
) )
@ -301,8 +377,8 @@ export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps)
const thumb = scrollable ? Math.max(1, Math.round((vp * vp) / total)) : vp const thumb = scrollable ? Math.max(1, Math.round((vp * vp) / total)) : vp
const travel = Math.max(1, vp - thumb) const travel = Math.max(1, vp - thumb)
const thumbTop = scrollable ? Math.round((pos / Math.max(1, total - vp)) * travel) : 0 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 thumbColor = grab !== null ? t.color.primary : hover ? t.color.accent : t.color.border
const trackColor = hover ? t.color.bronze : t.color.dim const trackColor = hover ? t.color.border : t.color.muted
const jump = (row: number, offset: number) => { const jump = (row: number, offset: number) => {
if (!s || !scrollable) { if (!s || !scrollable) {

View file

@ -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 { useStore } from '@nanostores/react'
import { Fragment, memo, useMemo, useRef } from 'react' import { Fragment, memo, useMemo, useRef } from 'react'
@ -124,8 +124,10 @@ const ComposerPane = memo(function ComposerPane({
const ui = useStore($uiState) const ui = useStore($uiState)
const isBlocked = useStore($isBlocked) const isBlocked = useStore($isBlocked)
const sh = (composer.inputBuf[0] ?? composer.input).startsWith('!') const sh = (composer.inputBuf[0] ?? composer.input).startsWith('!')
const pw = 2 const promptText = sh ? '$' : ui.theme.brand.prompt
const inputColumns = stableComposerColumns(composer.cols, pw) const promptLabel = `${promptText} `
const promptWidth = Math.max(1, stringWidth(promptLabel))
const inputColumns = stableComposerColumns(composer.cols, promptWidth)
const inputHeight = inputVisualHeight(composer.input, inputColumns) const inputHeight = inputVisualHeight(composer.input, inputColumns)
const inputMouseRef = useRef<null | TextInputMouseApi>(null) const inputMouseRef = useRef<null | TextInputMouseApi>(null)
@ -146,7 +148,7 @@ const ComposerPane = memo(function ComposerPane({
} }
e.stopImmediatePropagation?.() 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 // Spacer rows live on a different vertical origin; only the column is
@ -158,7 +160,7 @@ const ComposerPane = memo(function ComposerPane({
} }
e.stopImmediatePropagation?.() e.stopImmediatePropagation?.()
inputMouseRef.current?.dragAt(0, (e.localCol ?? 0) - pw) inputMouseRef.current?.dragAt(0, (e.localCol ?? 0) - promptWidth)
} }
const endInputDrag = () => inputMouseRef.current?.end() const endInputDrag = () => inputMouseRef.current?.end()
@ -183,13 +185,13 @@ const ComposerPane = memo(function ComposerPane({
/> />
{ui.bgTasks.size > 0 && ( {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 {ui.bgTasks.size} background {ui.bgTasks.size === 1 ? 'task' : 'tasks'} running
</Text> </Text>
)} )}
{status.showStickyPrompt ? ( {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> <Text color={ui.theme.color.label}> </Text>
{status.stickyPrompt} {status.stickyPrompt}
@ -214,21 +216,21 @@ const ComposerPane = memo(function ComposerPane({
<> <>
{composer.inputBuf.map((line, i) => ( {composer.inputBuf.map((line, i) => (
<Box key={i}> <Box key={i}>
<Box width={2}> <Box width={promptWidth}>
<Text color={ui.theme.color.dim}>{i === 0 ? `${ui.theme.brand.prompt} ` : ' '}</Text> <Text color={ui.theme.color.muted}>{i === 0 ? promptLabel : ' '.repeat(promptWidth)}</Text>
</Box> </Box>
<Text color={ui.theme.color.cornsilk}>{line || ' '}</Text> <Text color={ui.theme.color.text}>{line || ' '}</Text>
</Box> </Box>
))} ))}
<Box onMouseDown={captureInputDrag} onMouseDrag={dragFromPromptRow} onMouseUp={endInputDrag} position="relative"> <Box onMouseDown={captureInputDrag} onMouseDrag={dragFromPromptRow} onMouseUp={endInputDrag} position="relative">
<Box width={pw}> <Box width={promptWidth}>
{sh ? ( {sh ? (
<Text color={ui.theme.color.shellDollar}>$ </Text> <Text color={ui.theme.color.shellDollar}>{promptLabel}</Text>
) : ( ) : (
<Text bold color={ui.theme.color.prompt}> <Text bold color={ui.theme.color.prompt}>
{composer.inputBuf.length ? ' ' : `${ui.theme.brand.prompt} `} {composer.inputBuf.length ? ' '.repeat(promptWidth) : promptLabel}
</Text> </Text>
)} )}
</Box> </Box>
@ -254,7 +256,7 @@ const ComposerPane = memo(function ComposerPane({
)} )}
</Box> </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} /> <StatusRulePane at="bottom" composer={composer} status={status} />
</NoSelect> </NoSelect>
@ -319,6 +321,7 @@ export const AppLayout = memo(function AppLayout({
transcript transcript
}: AppLayoutProps) { }: AppLayoutProps) {
const overlay = useStore($overlayState) const overlay = useStore($overlayState)
const ui = useStore($uiState)
// Inline mode skips AlternateScreen so the host terminal's native // Inline mode skips AlternateScreen so the host terminal's native
// scrollback captures rows scrolled off the top; composer + progress // scrollback captures rows scrolled off the top; composer + progress
@ -359,7 +362,7 @@ export const AppLayout = memo(function AppLayout({
{SHOW_FPS && ( {SHOW_FPS && (
<Box flexShrink={0} justifyContent="flex-end" paddingRight={1}> <Box flexShrink={0} justifyContent="flex-end" paddingRight={1}>
<FpsOverlay /> <FpsOverlay t={ui.theme} />
</Box> </Box>
)} )}
</> </>

View file

@ -119,7 +119,7 @@ export function FloatingOverlays({
return ( return (
<Box alignItems="flex-start" bottom="100%" flexDirection="column" left={0} position="absolute" right={0}> <Box alignItems="flex-start" bottom="100%" flexDirection="column" left={0} position="absolute" right={0}>
{overlay.picker && ( {overlay.picker && (
<FloatBox color={ui.theme.color.bronze}> <FloatBox color={ui.theme.color.border}>
<SessionPicker <SessionPicker
gw={gw} gw={gw}
onCancel={() => patchOverlayState({ picker: false })} onCancel={() => patchOverlayState({ picker: false })}
@ -130,7 +130,7 @@ export function FloatingOverlays({
)} )}
{overlay.modelPicker && ( {overlay.modelPicker && (
<FloatBox color={ui.theme.color.bronze}> <FloatBox color={ui.theme.color.border}>
<ModelPicker <ModelPicker
gw={gw} gw={gw}
onCancel={() => patchOverlayState({ modelPicker: false })} onCancel={() => patchOverlayState({ modelPicker: false })}
@ -142,17 +142,17 @@ export function FloatingOverlays({
)} )}
{overlay.skillsHub && ( {overlay.skillsHub && (
<FloatBox color={ui.theme.color.bronze}> <FloatBox color={ui.theme.color.border}>
<SkillsHub gw={gw} onClose={() => patchOverlayState({ skillsHub: false })} t={ui.theme} /> <SkillsHub gw={gw} onClose={() => patchOverlayState({ skillsHub: false })} t={ui.theme} />
</FloatBox> </FloatBox>
)} )}
{overlay.pager && ( {overlay.pager && (
<FloatBox color={ui.theme.color.bronze}> <FloatBox color={ui.theme.color.border}>
<Box flexDirection="column" paddingX={1} paddingY={1}> <Box flexDirection="column" paddingX={1} paddingY={1}>
{overlay.pager.title && ( {overlay.pager.title && (
<Box justifyContent="center" marginBottom={1}> <Box justifyContent="center" marginBottom={1}>
<Text bold color={ui.theme.color.gold}> <Text bold color={ui.theme.color.primary}>
{overlay.pager.title} {overlay.pager.title}
</Text> </Text>
</Box> </Box>
@ -174,7 +174,7 @@ export function FloatingOverlays({
)} )}
{!!completions.length && ( {!!completions.length && (
<FloatBox color={ui.theme.color.gold}> <FloatBox color={ui.theme.color.primary}>
<Box flexDirection="column" width={Math.max(28, cols - 6)}> <Box flexDirection="column" width={Math.max(28, cols - 6)}>
{completions.slice(start, start + viewportSize).map((item, i) => { {completions.slice(start, start + viewportSize).map((item, i) => {
const active = start + i === compIdx const active = start + i === compIdx
@ -190,7 +190,7 @@ export function FloatingOverlays({
{' '} {' '}
{item.display} {item.display}
</Text> </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> </Box>
) )
})} })}

View file

@ -26,12 +26,12 @@ export function Banner({ t }: { t: Theme }) {
{cols >= (t.bannerLogo ? artWidth(logoLines) : LOGO_WIDTH) ? ( {cols >= (t.bannerLogo ? artWidth(logoLines) : LOGO_WIDTH) ? (
<ArtLines lines={logoLines} /> <ArtLines lines={logoLines} />
) : ( ) : (
<Text bold color={t.color.gold}> <Text bold color={t.color.primary}>
{t.brand.icon} NOUS HERMES {t.brand.icon} NOUS HERMES
</Text> </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> </Box>
) )
} }
@ -70,19 +70,19 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) {
return ( return (
<Box flexDirection="column" marginTop={1}> <Box flexDirection="column" marginTop={1}>
<Text bold color={t.color.amber}> <Text bold color={t.color.accent}>
Available {title} Available {title}
</Text> </Text>
{shown.map(([k, vs]) => ( {shown.map(([k, vs]) => (
<Text key={k} wrap="truncate"> <Text key={k} wrap="truncate">
<Text color={t.color.dim}>{strip(k)}: </Text> <Text color={t.color.muted}>{strip(k)}: </Text>
<Text color={t.color.cornsilk}>{truncLine(strip(k) + ': ', vs)}</Text> <Text color={t.color.text}>{truncLine(strip(k) + ': ', vs)}</Text>
</Text> </Text>
))} ))}
{overflow > 0 && ( {overflow > 0 && (
<Text color={t.color.dim}> <Text color={t.color.muted}>
(and {overflow} {overflowLabel}) (and {overflow} {overflowLabel})
</Text> </Text>
)} )}
@ -91,18 +91,18 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) {
} }
return ( 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 && ( {wide && (
<Box flexDirection="column" marginRight={2} width={leftW}> <Box flexDirection="column" marginRight={2} width={leftW}>
<ArtLines lines={heroLines} /> <ArtLines lines={heroLines} />
<Text /> <Text />
<Text color={t.color.amber}> <Text color={t.color.accent}>
{info.model.split('/').pop()} {info.model.split('/').pop()}
<Text color={t.color.dim}> · Nous Research</Text> <Text color={t.color.muted}> · Nous Research</Text>
</Text> </Text>
<Text color={t.color.dim} wrap="truncate-end"> <Text color={t.color.muted} wrap="truncate-end">
{info.cwd || process.cwd()} {info.cwd || process.cwd()}
</Text> </Text>
@ -117,7 +117,7 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) {
<Box flexDirection="column" width={w}> <Box flexDirection="column" width={w}>
<Box justifyContent="center" marginBottom={1}> <Box justifyContent="center" marginBottom={1}>
<Text bold color={t.color.gold}> <Text bold color={t.color.primary}>
{t.brand.name} {t.brand.name}
{info.version ? ` v${info.version}` : ''} {info.version ? ` v${info.version}` : ''}
{info.release_date ? ` (${info.release_date})` : ''} {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 && ( {info.mcp_servers && info.mcp_servers.length > 0 && (
<Box flexDirection="column" marginTop={1}> <Box flexDirection="column" marginTop={1}>
<Text bold color={t.color.amber}> <Text bold color={t.color.accent}>
MCP Servers MCP Servers
</Text> </Text>
{info.mcp_servers.map(s => ( {info.mcp_servers.map(s => (
<Text key={s.name} wrap="truncate"> <Text key={s.name} wrap="truncate">
<Text color={t.color.dim}>{` ${s.name} `}</Text> <Text color={t.color.muted}>{` ${s.name} `}</Text>
<Text color={t.color.dim}>{`[${s.transport}]`}</Text> <Text color={t.color.muted}>{`[${s.transport}]`}</Text>
<Text color={t.color.dim}>: </Text> <Text color={t.color.muted}>: </Text>
{s.connected ? ( {s.connected ? (
<Text color={t.color.cornsilk}> <Text color={t.color.text}>
{s.tools} tool{s.tools === 1 ? '' : 's'} {s.tools} tool{s.tools === 1 ? '' : 's'}
</Text> </Text>
) : ( ) : (
@ -152,12 +152,12 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) {
<Text /> <Text />
<Text color={t.color.cornsilk}> <Text color={t.color.text}>
{flat(info.tools).length} tools{' · '} {flat(info.tools).length} tools{' · '}
{flat(info.skills).length} skills {flat(info.skills).length} skills
{info.mcp_servers?.length ? ` · ${info.mcp_servers.length} MCP` : ''} {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> </Text>
{typeof info.update_behind === 'number' && info.update_behind > 0 && ( {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) { export function Panel({ sections, t, title }: PanelProps) {
return ( 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}> <Box justifyContent="center" marginBottom={1}>
<Text bold color={t.color.gold}> <Text bold color={t.color.primary}>
{title} {title}
</Text> </Text>
</Box> </Box>
@ -193,25 +193,25 @@ export function Panel({ sections, t, title }: PanelProps) {
{sections.map((sec, si) => ( {sections.map((sec, si) => (
<Box flexDirection="column" key={si} marginTop={si > 0 ? 1 : 0}> <Box flexDirection="column" key={si} marginTop={si > 0 ? 1 : 0}>
{sec.title && ( {sec.title && (
<Text bold color={t.color.amber}> <Text bold color={t.color.accent}>
{sec.title} {sec.title}
</Text> </Text>
)} )}
{sec.rows?.map(([k, v], ri) => ( {sec.rows?.map(([k, v], ri) => (
<Text key={ri} wrap="truncate"> <Text key={ri} wrap="truncate">
<Text color={t.color.dim}>{k.padEnd(20)}</Text> <Text color={t.color.muted}>{k.padEnd(20)}</Text>
<Text color={t.color.cornsilk}>{v}</Text> <Text color={t.color.text}>{v}</Text>
</Text> </Text>
))} ))}
{sec.items?.map((item, ii) => ( {sec.items?.map((item, ii) => (
<Text color={t.color.cornsilk} key={ii} wrap="truncate"> <Text color={t.color.text} key={ii} wrap="truncate">
{item} {item}
</Text> </Text>
))} ))}
{sec.text && <Text color={t.color.dim}>{sec.text}</Text>} {sec.text && <Text color={t.color.muted}>{sec.text}</Text>}
</Box> </Box>
))} ))}
</Box> </Box>

View file

@ -5,23 +5,25 @@ import { useStore } from '@nanostores/react'
import { SHOW_FPS } from '../config/env.js' import { SHOW_FPS } from '../config/env.js'
import { $fpsState } from '../lib/fpsStore.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) { if (!SHOW_FPS) {
return null return null
} }
return <FpsOverlayInner /> return <FpsOverlayInner t={t} />
} }
function FpsOverlayInner() { function FpsOverlayInner({ t }: { t: Theme }) {
const { fps, lastDurationMs, totalFrames } = useStore($fpsState) const { fps, lastDurationMs, totalFrames } = useStore($fpsState)
// Zero-pad widths so digit churn doesn't jitter the corner. // Zero-pad widths so digit churn doesn't jitter the corner.
return ( return (
<Text color={fpsColor(fps)}> <Text color={fpsColor(fps, t)}>
{fps.toFixed(1).padStart(5)}fps · {lastDurationMs.toFixed(1).padStart(5)}ms · #{totalFrames} {fps.toFixed(1).padStart(5)}fps · {lastDurationMs.toFixed(1).padStart(5)}ms · #{totalFrames}
</Text> </Text>
) )

View file

@ -1,5 +1,5 @@
import { Box, Link, Text } from '@hermes/ink' import { Box, Link, Text } from '@hermes/ink'
import { memo, type ReactNode, useMemo } from 'react' import { Fragment, memo, type ReactNode, useMemo } from 'react'
import { ensureEmojiPresentation } from '../lib/emoji.js' import { ensureEmojiPresentation } from '../lib/emoji.js'
import { BOX_CLOSE, BOX_OPEN, texToUnicode } from '../lib/mathUnicode.js' import { BOX_CLOSE, BOX_OPEN, texToUnicode } from '../lib/mathUnicode.js'
@ -9,9 +9,10 @@ import type { Theme } from '../theme.js'
// `\boxed{X}` regions in `texToUnicode` output are marked with the // `\boxed{X}` regions in `texToUnicode` output are marked with the
// non-printable U+0001 / U+0002 sentinels. Split on them and render the // non-printable U+0001 / U+0002 sentinels. Split on them and render the
// boxed segment with `inverse + bold` so it reads as a highlighter-pen // boxed segment with `inverse + bold` so it reads as a highlighter-pen
// emphasis on top of whatever color the parent `<Text>` is using (amber // emphasis on top of whatever color the parent `<Text>` is using (the
// for math). The leading / trailing space inside the highlight gives a // theme accent for math). The leading / trailing space inside the
// one-cell visual margin so the highlight reads as a block, not a hug. // highlight gives a one-cell visual margin so the highlight reads as a
// block, not a hug.
const renderMath = (text: string): ReactNode => { const renderMath = (text: string): ReactNode => {
if (!text.includes(BOX_OPEN)) { if (!text.includes(BOX_OPEN)) {
return text return text
@ -144,7 +145,7 @@ const autolinkUrl = (raw: string) =>
const renderAutolink = (k: number, t: Theme, raw: string) => ( const renderAutolink = (k: number, t: Theme, raw: string) => (
<Link key={k} url={autolinkUrl(raw)}> <Link key={k} url={autolinkUrl(raw)}>
<Text color={t.color.amber} underline> <Text color={t.color.accent} underline>
{raw.replace(/^mailto:/, '')} {raw.replace(/^mailto:/, '')}
</Text> </Text>
</Link> </Link>
@ -171,18 +172,35 @@ export const stripInlineMarkup = (v: string) =>
const renderTable = (k: number, rows: string[][], t: Theme) => { const renderTable = (k: number, rows: string[][], t: Theme) => {
const widths = rows[0]!.map((_, ci) => Math.max(...rows.map(r => stripInlineMarkup(r[ci] ?? '').length))) const widths = rows[0]!.map((_, ci) => Math.max(...rows.map(r => stripInlineMarkup(r[ci] ?? '').length)))
// Thin divider under the header. Without it tables look like prose
// with extra spacing because the header is just accent-coloured text
// (#15534). We avoid full borders on purpose — column widths come
// from `stripInlineMarkup(...).length` (UTF-16 code units, not
// display width), so a real outline often misaligns on emoji and
// East-Asian wide characters; one dim solid rule (`─`) under row 0
// plus tab-style column gaps reads cleanly on every terminal we
// tested.
const sep = widths.map(w => '─'.repeat(Math.max(1, w))).join(' ')
return ( return (
<Box flexDirection="column" key={k} paddingLeft={2}> <Box flexDirection="column" key={k} paddingLeft={2}>
{rows.map((row, ri) => ( {rows.map((row, ri) => (
<Box key={ri}> <Fragment key={ri}>
{widths.map((w, ci) => ( <Box>
<Text color={ri === 0 ? t.color.amber : undefined} key={ci}> {widths.map((w, ci) => (
<MdInline t={t} text={row[ci] ?? ''} /> <Text bold={ri === 0} color={ri === 0 ? t.color.accent : undefined} key={ci}>
{' '.repeat(Math.max(0, w - stripInlineMarkup(row[ci] ?? '').length))} <MdInline t={t} text={row[ci] ?? ''} />
{ci < widths.length - 1 ? ' ' : ''} {' '.repeat(Math.max(0, w - stripInlineMarkup(row[ci] ?? '').length))}
{ci < widths.length - 1 ? ' ' : ''}
</Text>
))}
</Box>
{ri === 0 && rows.length > 1 ? (
<Text color={t.color.muted} dimColor>
{sep}
</Text> </Text>
))} ) : null}
</Box> </Fragment>
))} ))}
</Box> </Box>
) )
@ -203,14 +221,14 @@ function MdInline({ t, text }: { t: Theme; text: string }) {
if (m[1] && m[2]) { if (m[1] && m[2]) {
parts.push( parts.push(
<Text color={t.color.dim} key={parts.length}> <Text color={t.color.muted} key={parts.length}>
[image: {m[1]}] {m[2]} [image: {m[1]}] {m[2]}
</Text> </Text>
) )
} else if (m[3] && m[4]) { } else if (m[3] && m[4]) {
parts.push( parts.push(
<Link key={parts.length} url={m[4]}> <Link key={parts.length} url={m[4]}>
<Text color={t.color.amber} underline> <Text color={t.color.accent} underline>
{m[3]} {m[3]}
</Text> </Text>
</Link> </Link>
@ -228,7 +246,7 @@ function MdInline({ t, text }: { t: Theme; text: string }) {
// are verbatim by definition. Letting MdInline reprocess them // are verbatim by definition. Letting MdInline reprocess them
// would corrupt regex examples and shell snippets. // would corrupt regex examples and shell snippets.
parts.push( parts.push(
<Text color={t.color.amber} dimColor key={parts.length}> <Text color={t.color.accent} dimColor key={parts.length}>
{m[7]} {m[7]}
</Text> </Text>
) )
@ -257,19 +275,19 @@ function MdInline({ t, text }: { t: Theme; text: string }) {
) )
} else if (m[13]) { } else if (m[13]) {
parts.push( parts.push(
<Text color={t.color.dim} key={parts.length}> <Text color={t.color.muted} key={parts.length}>
[{m[13]}] [{m[13]}]
</Text> </Text>
) )
} else if (m[14]) { } else if (m[14]) {
parts.push( parts.push(
<Text color={t.color.dim} key={parts.length}> <Text color={t.color.muted} key={parts.length}>
^{m[14]} ^{m[14]}
</Text> </Text>
) )
} else if (m[15]) { } else if (m[15]) {
parts.push( parts.push(
<Text color={t.color.dim} key={parts.length}> <Text color={t.color.muted} key={parts.length}>
_{m[15]} _{m[15]}
</Text> </Text>
) )
@ -286,13 +304,13 @@ function MdInline({ t, text }: { t: Theme; text: string }) {
} else if (m[17] ?? m[18]) { } else if (m[17] ?? m[18]) {
// Inline math is run through `texToUnicode` (Greek letters, , // Inline math is run through `texToUnicode` (Greek letters, ,
// operators, sub/superscripts, fractions) and rendered in italic // operators, sub/superscripts, fractions) and rendered in italic
// amber. Italic is the disambiguator — links use amber+underline, // accent. Italic is the disambiguator — links use accent+underline,
// so without italic readers can't tell `\mathbb{R}` (math) from a // so without italic readers can't tell `\mathbb{R}` (math) from a
// hyperlinked word. Anything `texToUnicode` doesn't recognise is // hyperlinked word. Anything `texToUnicode` doesn't recognise is
// preserved verbatim, so unfamiliar commands just look like their // preserved verbatim, so unfamiliar commands just look like their
// raw LaTeX rather than vanishing. // raw LaTeX rather than vanishing.
parts.push( parts.push(
<Text color={t.color.amber} italic key={parts.length}> <Text color={t.color.accent} italic key={parts.length}>
{renderMath(texToUnicode(m[17] ?? m[18]!))} {renderMath(texToUnicode(m[17] ?? m[18]!))}
</Text> </Text>
) )
@ -402,11 +420,11 @@ function MdImpl({ compact, t, text }: MdProps) {
if (media) { if (media) {
start('paragraph') start('paragraph')
nodes.push( 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}> <Link url={/^(?:\/|[a-z]:[\\/])/i.test(media) ? `file://${media}` : media}>
<Text color={t.color.amber} underline> <Text color={t.color.accent} underline>
{media} {media}
</Text> </Text>
</Link> </Link>
@ -453,7 +471,7 @@ function MdImpl({ compact, t, text }: MdProps) {
nodes.push( nodes.push(
<Box flexDirection="column" key={key} paddingLeft={2}> <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) => { {block.map((l, j) => {
if (highlighted) { if (highlighted) {
@ -479,7 +497,7 @@ function MdImpl({ compact, t, text }: MdProps) {
return ( return (
<Text <Text
backgroundColor={add ? t.color.diffAdded : del ? t.color.diffRemoved : undefined} 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(' ')} dimColor={isDiff && !add && !del && !hunk && l.startsWith(' ')}
key={j} key={j}
> >
@ -513,7 +531,7 @@ function MdImpl({ compact, t, text }: MdProps) {
start('code') start('code')
nodes.push( nodes.push(
<Box flexDirection="column" key={key} paddingLeft={2}> <Box flexDirection="column" key={key} paddingLeft={2}>
{inner ? <Text color={t.color.amber}>{renderMath(texToUnicode(inner))}</Text> : null} {inner ? <Text color={t.color.accent}>{renderMath(texToUnicode(inner))}</Text> : null}
</Box> </Box>
) )
i++ i++
@ -560,7 +578,7 @@ function MdImpl({ compact, t, text }: MdProps) {
nodes.push( nodes.push(
<Box flexDirection="column" key={key} paddingLeft={2}> <Box flexDirection="column" key={key} paddingLeft={2}>
{block.map((l, j) => ( {block.map((l, j) => (
<Text color={t.color.amber} key={j}> <Text color={t.color.accent} key={j}>
{renderMath(texToUnicode(l))} {renderMath(texToUnicode(l))}
</Text> </Text>
))} ))}
@ -576,7 +594,7 @@ function MdImpl({ compact, t, text }: MdProps) {
if (heading) { if (heading) {
start('heading') start('heading')
nodes.push( nodes.push(
<Text bold color={t.color.amber} key={key}> <Text bold color={t.color.accent} key={key}>
<MdInline t={t} text={heading} /> <MdInline t={t} text={heading} />
</Text> </Text>
) )
@ -588,7 +606,7 @@ function MdImpl({ compact, t, text }: MdProps) {
if (i + 1 < lines.length && SETEXT_RE.test(lines[i + 1]!)) { if (i + 1 < lines.length && SETEXT_RE.test(lines[i + 1]!)) {
start('heading') start('heading')
nodes.push( nodes.push(
<Text bold color={t.color.amber} key={key}> <Text bold color={t.color.accent} key={key}>
<MdInline t={t} text={line.trim()} /> <MdInline t={t} text={line.trim()} />
</Text> </Text>
) )
@ -600,7 +618,7 @@ function MdImpl({ compact, t, text }: MdProps) {
if (HR_RE.test(line)) { if (HR_RE.test(line)) {
start('rule') start('rule')
nodes.push( nodes.push(
<Text color={t.color.dim} key={key}> <Text color={t.color.muted} key={key}>
{'─'.repeat(36)} {'─'.repeat(36)}
</Text> </Text>
) )
@ -614,7 +632,7 @@ function MdImpl({ compact, t, text }: MdProps) {
if (footnote) { if (footnote) {
start('list') start('list')
nodes.push( nodes.push(
<Text color={t.color.dim} key={key}> <Text color={t.color.muted} key={key}>
[{footnote[1]}] <MdInline t={t} text={footnote[2] ?? ''} /> [{footnote[1]}] <MdInline t={t} text={footnote[2] ?? ''} />
</Text> </Text>
) )
@ -623,7 +641,7 @@ function MdImpl({ compact, t, text }: MdProps) {
while (i < lines.length && /^\s{2,}\S/.test(lines[i]!)) { while (i < lines.length && /^\s{2,}\S/.test(lines[i]!)) {
nodes.push( nodes.push(
<Box key={`${key}-cont-${i}`} paddingLeft={2}> <Box key={`${key}-cont-${i}`} paddingLeft={2}>
<Text color={t.color.dim}> <Text color={t.color.muted}>
<MdInline t={t} text={lines[i]!.trim()} /> <MdInline t={t} text={lines[i]!.trim()} />
</Text> </Text>
</Box> </Box>
@ -652,7 +670,7 @@ function MdImpl({ compact, t, text }: MdProps) {
nodes.push( nodes.push(
<Text key={`${key}-def-${i}`}> <Text key={`${key}-def-${i}`}>
<Text color={t.color.dim}> · </Text> <Text color={t.color.muted}> · </Text>
<MdInline t={t} text={def} /> <MdInline t={t} text={def} />
</Text> </Text>
) )
@ -672,7 +690,7 @@ function MdImpl({ compact, t, text }: MdProps) {
nodes.push( nodes.push(
<Text key={key}> <Text key={key}>
<Text color={t.color.dim}> <Text color={t.color.muted}>
{' '.repeat(indentDepth(bullet[1]!) * 2)} {' '.repeat(indentDepth(bullet[1]!) * 2)}
{marker}{' '} {marker}{' '}
</Text> </Text>
@ -691,7 +709,7 @@ function MdImpl({ compact, t, text }: MdProps) {
start('list') start('list')
nodes.push( nodes.push(
<Text key={key}> <Text key={key}>
<Text color={t.color.dim}> <Text color={t.color.muted}>
{' '.repeat(indentDepth(numbered[1]!) * 2)} {' '.repeat(indentDepth(numbered[1]!) * 2)}
{numbered[2]}.{' '} {numbered[2]}.{' '}
</Text> </Text>
@ -719,7 +737,7 @@ function MdImpl({ compact, t, text }: MdProps) {
nodes.push( nodes.push(
<Box flexDirection="column" key={key}> <Box flexDirection="column" key={key}>
{quoteLines.map((ql, qi) => ( {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)} {' '.repeat(Math.max(0, ql.depth - 1) * 2)}
{'│ '} {'│ '}
<MdInline t={t} text={ql.text} /> <MdInline t={t} text={ql.text} />
@ -756,7 +774,7 @@ function MdImpl({ compact, t, text }: MdProps) {
if (summary) { if (summary) {
start('paragraph') start('paragraph')
nodes.push( nodes.push(
<Text color={t.color.dim} key={key}> <Text color={t.color.muted} key={key}>
{summary} {summary}
</Text> </Text>
) )
@ -768,7 +786,7 @@ function MdImpl({ compact, t, text }: MdProps) {
if (/^<\/?[^>]+>$/.test(line.trim())) { if (/^<\/?[^>]+>$/.test(line.trim())) {
start('paragraph') start('paragraph')
nodes.push( nodes.push(
<Text color={t.color.dim} key={key}> <Text color={t.color.muted} key={key}>
{line.trim()} {line.trim()}
</Text> </Text>
) )

View file

@ -14,7 +14,7 @@ export function MaskedPrompt({ cols = 80, icon, label, onSubmit, sub, t }: Maske
{icon} {label} {icon} {label}
</Text> </Text>
{sub && <Text color={t.color.dim}> {sub}</Text>} {sub && <Text color={t.color.muted}> {sub}</Text>}
<Box> <Box>
<Text color={t.color.label}>{'> '}</Text> <Text color={t.color.label}>{'> '}</Text>

View file

@ -80,13 +80,13 @@ export const MessageLine = memo(function MessageLine({
const preview = compactPreview(stripped, maxChars) || '(empty tool result)' const preview = compactPreview(stripped, maxChars) || '(empty tool result)'
return ( 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) ? ( {hasAnsi(msg.text) ? (
<Text wrap="truncate-end"> <Text wrap="truncate-end">
<Ansi>{msg.text}</Ansi> <Ansi>{msg.text}</Ansi>
</Text> </Text>
) : ( ) : (
<Text color={t.color.dim} wrap="truncate-end"> <Text color={t.color.muted} wrap="truncate-end">
{preview} {preview}
</Text> </Text>
)} )}
@ -101,7 +101,7 @@ export const MessageLine = memo(function MessageLine({
const content = (() => { const content = (() => {
if (msg.kind === 'slash') { 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)) { if (msg.role !== 'user' && hasAnsi(msg.text)) {
@ -125,7 +125,7 @@ export const MessageLine = memo(function MessageLine({
return ( return (
<Text color={body}> <Text color={body}>
{head} {head}
<Text color={t.color.dim} dimColor> <Text color={t.color.muted} dimColor>
[long message] [long message]
</Text> </Text>
{rest.join('')} {rest.join('')}

View file

@ -146,7 +146,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
}) })
if (loading) { if (loading) {
return <Text color={t.color.dim}>loading models</Text> return <Text color={t.color.muted}>loading models</Text>
} }
if (err) { if (err) {
@ -161,7 +161,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
if (!providers.length) { if (!providers.length) {
return ( return (
<Box flexDirection="column"> <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> <OverlayHint t={t}>Esc/q cancel</OverlayHint>
</Box> </Box>
) )
@ -176,21 +176,21 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
return ( return (
<Box flexDirection="column" width={width}> <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) Select provider (step 1/2)
</Text> </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 Full model IDs on the next step · Enter to continue
</Text> </Text>
<Text color={t.color.dim} wrap="truncate-end"> <Text color={t.color.muted} wrap="truncate-end">
Current: {currentModel || '(unknown)'} Current: {currentModel || '(unknown)'}
</Text> </Text>
<Text color={t.color.label} wrap="truncate-end"> <Text color={t.color.label} wrap="truncate-end">
{provider?.warning ? `warning: ${provider.warning}` : ' '} {provider?.warning ? `warning: ${provider.warning}` : ' '}
</Text> </Text>
<Text color={t.color.dim} wrap="truncate-end"> <Text color={t.color.muted} wrap="truncate-end">
{offset > 0 ? `${offset} more` : ' '} {offset > 0 ? `${offset} more` : ' '}
</Text> </Text>
@ -201,7 +201,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
return row ? ( return row ? (
<Text <Text
bold={providerIdx === idx} bold={providerIdx === idx}
color={providerIdx === idx ? t.color.amber : t.color.dim} color={providerIdx === idx ? t.color.accent : t.color.muted}
inverse={providerIdx === idx} inverse={providerIdx === idx}
key={providers[idx]?.slug ?? `row-${idx}`} key={providers[idx]?.slug ?? `row-${idx}`}
wrap="truncate-end" wrap="truncate-end"
@ -210,17 +210,17 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
{i + 1}. {row} {i + 1}. {row}
</Text> </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>
) )
})} })}
<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` : ' '} {offset + VISIBLE < rows.length ? `${rows.length - offset - VISIBLE} more` : ' '}
</Text> </Text>
<Text color={t.color.dim} wrap="truncate-end"> <Text color={t.color.muted} wrap="truncate-end">
persist: {persistGlobal ? 'global' : 'session'} · g toggle persist: {persistGlobal ? 'global' : 'session'} · g toggle
</Text> </Text>
<OverlayHint t={t}>/ select · Enter choose · 1-9,0 quick · Esc/q cancel</OverlayHint> <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 ( return (
<Box flexDirection="column" width={width}> <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) Select model (step 2/2)
</Text> </Text>
<Text color={t.color.dim} wrap="truncate-end"> <Text color={t.color.muted} wrap="truncate-end">
{names[providerIdx] || '(unknown provider)'} · Esc back {names[providerIdx] || '(unknown provider)'} · Esc back
</Text> </Text>
<Text color={t.color.label} wrap="truncate-end"> <Text color={t.color.label} wrap="truncate-end">
{provider?.warning ? `warning: ${provider.warning}` : ' '} {provider?.warning ? `warning: ${provider.warning}` : ' '}
</Text> </Text>
<Text color={t.color.dim} wrap="truncate-end"> <Text color={t.color.muted} wrap="truncate-end">
{offset > 0 ? `${offset} more` : ' '} {offset > 0 ? `${offset} more` : ' '}
</Text> </Text>
@ -252,11 +252,11 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
if (!row) { if (!row) {
return !models.length && i === 0 ? ( 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 no models listed for this provider
</Text> </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>
) )
@ -267,7 +267,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
return ( return (
<Text <Text
bold={modelIdx === idx} bold={modelIdx === idx}
color={modelIdx === idx ? t.color.amber : t.color.dim} color={modelIdx === idx ? t.color.accent : t.color.muted}
inverse={modelIdx === idx} inverse={modelIdx === idx}
key={`${provider?.slug ?? 'prov'}:${idx}:${row}`} key={`${provider?.slug ?? 'prov'}:${idx}:${row}`}
wrap="truncate-end" 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` : ' '} {offset + VISIBLE < models.length ? `${models.length - offset - VISIBLE} more` : ' '}
</Text> </Text>
<Text color={t.color.dim} wrap="truncate-end"> <Text color={t.color.muted} wrap="truncate-end">
persist: {persistGlobal ? 'global' : 'session'} · g toggle persist: {persistGlobal ? 'global' : 'session'} · g toggle
</Text> </Text>
<OverlayHint t={t}> <OverlayHint t={t}>

View file

@ -20,7 +20,7 @@ export function useOverlayKeys({ disabled = false, onBack, onClose }: OverlayKey
export function OverlayHint({ children, t }: OverlayHintProps) { export function OverlayHint({ children, t }: OverlayHintProps) {
return ( return (
<Text color={t.color.dim} wrap="truncate-end"> <Text color={t.color.muted} wrap="truncate-end">
{children} {children}
</Text> </Text>
) )

View file

@ -48,13 +48,13 @@ export function ApprovalPrompt({ onChoice, req, t }: ApprovalPromptProps) {
<Box flexDirection="column" paddingLeft={1}> <Box flexDirection="column" paddingLeft={1}>
{shown.map((line, i) => ( {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 || ' '} {line || ' '}
</Text> </Text>
))} ))}
{overflow > 0 ? ( {overflow > 0 ? (
<Text color={t.color.dim}> <Text color={t.color.muted}>
+{overflow} more line{overflow === 1 ? '' : 's'} (full text above) +{overflow} more line{overflow === 1 ? '' : 's'} (full text above)
</Text> </Text>
) : null} ) : null}
@ -64,14 +64,14 @@ export function ApprovalPrompt({ onChoice, req, t }: ApprovalPromptProps) {
{OPTS.map((o, i) => ( {OPTS.map((o, i) => (
<Text key={o}> <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 ? '▸ ' : ' '} {sel === i ? '▸ ' : ' '}
{i + 1}. {LABELS[o]} {i + 1}. {LABELS[o]}
</Text> </Text>
</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> </Box>
) )
} }
@ -84,8 +84,8 @@ export function ClarifyPrompt({ cols = 80, onAnswer, onCancel, req, t }: Clarify
const heading = ( const heading = (
<Text bold> <Text bold>
<Text color={t.color.amber}>ask</Text> <Text color={t.color.accent}>ask</Text>
<Text color={t.color.cornsilk}> {req.question}</Text> <Text color={t.color.text}> {req.question}</Text>
</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} /> <TextInput columns={Math.max(20, cols - 6)} onChange={setCustom} onSubmit={onAnswer} value={custom} />
</Box> </Box>
<Text color={t.color.dim}> <Text color={t.color.muted}>
Enter send · Esc {choices.length ? 'back' : 'cancel'} ·{' '} Enter send · Esc {choices.length ? 'back' : 'cancel'} ·{' '}
{isMac ? 'Cmd+C copy · Cmd+V paste · Ctrl+C cancel' : 'Ctrl+C cancel'} {isMac ? 'Cmd+C copy · Cmd+V paste · Ctrl+C cancel' : 'Ctrl+C cancel'}
</Text> </Text>
@ -143,14 +143,14 @@ export function ClarifyPrompt({ cols = 80, onAnswer, onCancel, req, t }: Clarify
{[...choices, 'Other (type your answer)'].map((c, i) => ( {[...choices, 'Other (type your answer)'].map((c, i) => (
<Text key={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 ? '▸ ' : ' '} {sel === i ? '▸ ' : ' '}
{i + 1}. {c} {i + 1}. {c}
</Text> </Text>
</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> </Box>
) )
} }
@ -185,8 +185,8 @@ export function ConfirmPrompt({ onCancel, onConfirm, req, t }: ConfirmPromptProp
const accent = req.danger ? t.color.error : t.color.warn const accent = req.danger ? t.color.error : t.color.warn
const rows = [ const rows = [
{ color: t.color.cornsilk, label: req.cancelLabel ?? 'No' }, { color: t.color.text, label: req.cancelLabel ?? 'No' },
{ color: req.danger ? t.color.error : t.color.cornsilk, label: req.confirmLabel ?? 'Yes' } { color: req.danger ? t.color.error : t.color.text, label: req.confirmLabel ?? 'Yes' }
] ]
return ( return (
@ -197,7 +197,7 @@ export function ConfirmPrompt({ onCancel, onConfirm, req, t }: ConfirmPromptProp
{req.detail ? ( {req.detail ? (
<Box paddingLeft={1}> <Box paddingLeft={1}>
<Text color={t.color.cornsilk} wrap="truncate-end"> <Text color={t.color.text} wrap="truncate-end">
{req.detail} {req.detail}
</Text> </Text>
</Box> </Box>
@ -207,12 +207,12 @@ export function ConfirmPrompt({ onCancel, onConfirm, req, t }: ConfirmPromptProp
{rows.map((row, i) => ( {rows.map((row, i) => (
<Text key={row.label}> <Text key={row.label}>
<Text color={sel === i ? accent : t.color.dim}>{sel === i ? '▸ ' : ' '}</Text> <Text color={sel === i ? accent : t.color.muted}>{sel === i ? '▸ ' : ' '}</Text>
<Text color={sel === i ? row.color : t.color.dim}>{row.label}</Text> <Text color={sel === i ? row.color : t.color.muted}>{row.label}</Text>
</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> </Box>
) )
} }

View file

@ -23,14 +23,14 @@ export function QueuedMessages({ cols, queueEditIdx, queued, t }: QueuedMessages
return ( return (
<Box flexDirection="column" marginTop={1}> <Box flexDirection="column" marginTop={1}>
<Text color={t.color.dim} dimColor> <Text color={t.color.muted} dimColor>
{`queued (${queued.length})${ {`queued (${queued.length})${
queueEditIdx !== null ? ` · editing ${queueEditIdx + 1} · Ctrl+X delete · Esc cancel` : '' queueEditIdx !== null ? ` · editing ${queueEditIdx + 1} · Ctrl+X delete · Esc cancel` : ''
}`} }`}
</Text> </Text>
{q.showLead && ( {q.showLead && (
<Text color={t.color.dim} dimColor> <Text color={t.color.muted} dimColor>
{' '} {' '}
</Text> </Text>
@ -41,14 +41,14 @@ export function QueuedMessages({ cols, queueEditIdx, queued, t }: QueuedMessages
const active = queueEditIdx === idx const active = queueEditIdx === idx
return ( 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))} {active ? '▸' : ' '} {idx + 1}. {compactPreview(item, Math.max(16, cols - 10))}
</Text> </Text>
) )
})} })}
{q.showTail && ( {q.showTail && (
<Text color={t.color.dim} dimColor> <Text color={t.color.muted} dimColor>
{' '}and {queued.length - q.end} more {' '}and {queued.length - q.end} more
</Text> </Text>
)} )}

View file

@ -80,7 +80,7 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps)
}) })
if (loading) { if (loading) {
return <Text color={t.color.dim}>loading sessions</Text> return <Text color={t.color.muted}>loading sessions</Text>
} }
if (err) { if (err) {
@ -95,7 +95,7 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps)
if (!items.length) { if (!items.length) {
return ( return (
<Box flexDirection="column"> <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> <OverlayHint t={t}>Esc/q cancel</OverlayHint>
</Box> </Box>
) )
@ -105,11 +105,11 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps)
return ( return (
<Box flexDirection="column" width={width}> <Box flexDirection="column" width={width}>
<Text bold color={t.color.amber}> <Text bold color={t.color.accent}>
Resume Session Resume Session
</Text> </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) => { {items.slice(offset, offset + VISIBLE).map((s, vi) => {
const i = offset + vi const i = offset + vi
@ -117,30 +117,30 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps)
return ( return (
<Box key={s.id}> <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 ? '▸ ' : ' '} {selected ? '▸ ' : ' '}
</Text> </Text>
<Box width={30}> <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}] {String(i + 1).padStart(2)}. [{s.id}]
</Text> </Text>
</Box> </Box>
<Box width={30}> <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'}) ({s.message_count} msgs, {age(s.started_at)}, {s.source || 'tui'})
</Text> </Text>
</Box> </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)'} {s.title || s.preview || '(untitled)'}
</Text> </Text>
</Box> </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> <OverlayHint t={t}>/ select · Enter resume · 1-9 quick · Esc/q cancel</OverlayHint>
</Box> </Box>
) )

View file

@ -179,7 +179,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
}) })
if (loading) { if (loading) {
return <Text color={t.color.dim}>loading skills</Text> return <Text color={t.color.muted}>loading skills</Text>
} }
if (err && stage === 'category') { if (err && stage === 'category') {
@ -194,7 +194,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
if (!cats.length) { if (!cats.length) {
return ( return (
<Box flexDirection="column" width={width}> <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> <OverlayHint t={t}>Esc/q cancel</OverlayHint>
</Box> </Box>
) )
@ -206,12 +206,12 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
return ( return (
<Box flexDirection="column" width={width}> <Box flexDirection="column" width={width}>
<Text bold color={t.color.amber}> <Text bold color={t.color.accent}>
Skills Hub Skills Hub
</Text> </Text>
<Text color={t.color.dim}>select a category</Text> <Text color={t.color.muted}>select a category</Text>
{offset > 0 && <Text color={t.color.dim}> {offset} more</Text>} {offset > 0 && <Text color={t.color.muted}> {offset} more</Text>}
{items.map((row, i) => { {items.map((row, i) => {
const idx = offset + i const idx = offset + i
@ -219,7 +219,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
return ( return (
<Text <Text
bold={catIdx === idx} bold={catIdx === idx}
color={catIdx === idx ? t.color.amber : t.color.dim} color={catIdx === idx ? t.color.accent : t.color.muted}
inverse={catIdx === idx} inverse={catIdx === idx}
key={row} key={row}
wrap="truncate-end" 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> <OverlayHint t={t}>/ select · Enter open · 1-9,0 quick · Esc/q cancel</OverlayHint>
</Box> </Box>
) )
@ -241,13 +241,13 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
return ( return (
<Box flexDirection="column" width={width}> <Box flexDirection="column" width={width}>
<Text bold color={t.color.amber}> <Text bold color={t.color.accent}>
{selectedCat} {selectedCat}
</Text> </Text>
<Text color={t.color.dim}>{skills.length} skill(s)</Text> <Text color={t.color.muted}>{skills.length} skill(s)</Text>
{!skills.length ? <Text color={t.color.dim}>no skills in this category</Text> : null} {!skills.length ? <Text color={t.color.muted}>no skills in this category</Text> : null}
{offset > 0 && <Text color={t.color.dim}> {offset} more</Text>} {offset > 0 && <Text color={t.color.muted}> {offset} more</Text>}
{items.map((row, i) => { {items.map((row, i) => {
const idx = offset + i const idx = offset + i
@ -255,7 +255,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
return ( return (
<Text <Text
bold={skillIdx === idx} bold={skillIdx === idx}
color={skillIdx === idx ? t.color.amber : t.color.dim} color={skillIdx === idx ? t.color.accent : t.color.muted}
inverse={skillIdx === idx} inverse={skillIdx === idx}
key={row} key={row}
wrap="truncate-end" wrap="truncate-end"
@ -267,7 +267,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
})} })}
{offset + VISIBLE < skills.length && ( {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}> <OverlayHint t={t}>
{skills.length ? '↑/↓ select · Enter open · 1-9,0 quick · Esc back · q close' : 'Esc back · q close'} {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 ( return (
<Box flexDirection="column" width={width}> <Box flexDirection="column" width={width}>
<Text bold color={t.color.amber}> <Text bold color={t.color.accent}>
{info?.name ?? skillName} {info?.name ?? skillName}
</Text> </Text>
<Text color={t.color.dim}>{info?.category ?? selectedCat}</Text> <Text color={t.color.muted}>{info?.category ?? selectedCat}</Text>
{info?.description ? <Text color={t.color.cornsilk}>{info.description}</Text> : null} {info?.description ? <Text color={t.color.text}>{info.description}</Text> : null}
{info?.path ? <Text color={t.color.dim}>path: {info.path}</Text> : null} {info?.path ? <Text color={t.color.muted}>path: {info.path}</Text> : null}
{!info && !err ? <Text color={t.color.dim}>loading</Text> : null} {!info && !err ? <Text color={t.color.muted}>loading</Text> : null}
{err ? <Text color={t.color.label}>error: {err}</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> <OverlayHint t={t}>i reinspect · x reinstall · Enter/Esc back · q close</OverlayHint>
</Box> </Box>

View file

@ -360,6 +360,10 @@ export function TextInput({
const nativeCursor = focus && termFocus && !selected && !!stdout?.isTTY 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(() => { const rendered = useMemo(() => {
if (!focus) { if (!focus) {
return display || dim(placeholder) return display || dim(placeholder)
@ -711,6 +715,14 @@ export function TextInput({
if (range && range.start === range.end) { if (range && range.start === range.end) {
selRef.current = null selRef.current = null
setSel(null) setSel(null)
return
}
const normalized = selRange()
if (isMac && normalized) {
void writeClipboardText(vRef.current.slice(normalized.start, normalized.end))
} }
} }

View file

@ -77,7 +77,7 @@ function TreeRow({
return ( return (
<Box> <Box>
<NoSelect flexShrink={0} fromLeftEdge width={lead.length}> <NoSelect flexShrink={0} fromLeftEdge width={lead.length}>
<Text color={stemColor ?? t.color.dim} dim={stemDim}> <Text color={stemColor ?? t.color.muted} dim={stemDim}>
{lead} {lead}
</Text> </Text>
</NoSelect> </NoSelect>
@ -246,12 +246,12 @@ function Chevron({
title: string title: string
tone?: 'dim' | 'error' | 'warn' 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 ( return (
<Box onClick={(e: any) => onClick(!!e?.shiftKey || !!e?.ctrlKey)}> <Box onClick={(e: any) => onClick(!!e?.shiftKey || !!e?.ctrlKey)}>
<Text color={color} dim={tone === 'dim'}> <Text color={color} dim={tone === 'dim'}>
<Text color={t.color.amber}>{open ? '▾ ' : '▸ '}</Text> <Text color={t.color.accent}>{open ? '▾ ' : '▸ '}</Text>
{title} {title}
{typeof count === 'number' ? ` (${count})` : ''} {typeof count === 'number' ? ` (${count})` : ''}
{suffix ? ( {suffix ? (
@ -266,7 +266,7 @@ function Chevron({
} }
function heatColor(node: SubagentNode, peak: number, theme: Theme): string | undefined { 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) const idx = hotnessBucket(node.aggregate.hotness, peak, palette.length)
// Below the median bucket we keep the default dim stem so cool branches // 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 hasTools = item.tools.length > 0
const noteRows = [...(summary ? [summary] : []), ...item.notes] const noteRows = [...(summary ? [summary] : []), ...item.notes]
const hasNotes = noteRows.length > 0 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: { const sections: {
header: ReactNode header: ReactNode
@ -460,10 +460,10 @@ function SubagentAccordion({
{item.tools.map((line, index) => ( {item.tools.map((line, index) => (
<TreeTextRow <TreeTextRow
branch={index === item.tools.length - 1 ? 'last' : 'mid'} branch={index === item.tools.length - 1 ? 'last' : 'mid'}
color={t.color.cornsilk} color={t.color.text}
content={ content={
<> <>
<Text color={t.color.amber}> </Text> <Text color={t.color.accent}> </Text>
{line} {line}
</> </>
} }
@ -649,22 +649,22 @@ export const Thinking = memo(function Thinking({
{preview ? ( {preview ? (
mode === 'full' ? ( mode === 'full' ? (
lines.map((line, index) => ( 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 || ' '} {line || ' '}
{index === lines.length - 1 ? ( {index === lines.length - 1 ? (
<StreamCursor color={t.color.dim} streaming={streaming} visible={active} /> <StreamCursor color={t.color.muted} streaming={streaming} visible={active} />
) : null} ) : null}
</Text> </Text>
)) ))
) : ( ) : (
<Text color={t.color.dim} wrap="truncate-end"> <Text color={t.color.muted} wrap="truncate-end">
{preview} {preview}
<StreamCursor color={t.color.dim} streaming={streaming} visible={active} /> <StreamCursor color={t.color.muted} streaming={streaming} visible={active} />
</Text> </Text>
) )
) : ( ) : (
<Text color={t.color.dim}> <Text color={t.color.muted}>
<StreamCursor color={t.color.dim} streaming={streaming} visible={active} /> <StreamCursor color={t.color.muted} streaming={streaming} visible={active} />
</Text> </Text>
)} )}
</Box> </Box>
@ -792,7 +792,7 @@ export const ToolTrail = memo(function ToolTrail({
if (parsed) { if (parsed) {
groups.push({ groups.push({
color: parsed.mark === '✗' ? t.color.error : t.color.cornsilk, color: parsed.mark === '✗' ? t.color.error : t.color.text,
content: parsed.call, content: parsed.call,
details: [], details: [],
key: `tr-${i}`, key: `tr-${i}`,
@ -801,7 +801,7 @@ export const ToolTrail = memo(function ToolTrail({
if (parsed.detail) { if (parsed.detail) {
pushDetail({ pushDetail({
color: parsed.mark === '✗' ? t.color.error : t.color.dim, color: parsed.mark === '✗' ? t.color.error : t.color.muted,
content: parsed.detail, content: parsed.detail,
dimColor: parsed.mark !== '✗', dimColor: parsed.mark !== '✗',
key: `tr-${i}-d` key: `tr-${i}-d`
@ -815,9 +815,9 @@ export const ToolTrail = memo(function ToolTrail({
const label = toolTrailLabel(line.slice(9).replace(/…$/, '').trim()) const label = toolTrailLabel(line.slice(9).replace(/…$/, '').trim())
groups.push({ groups.push({
color: t.color.cornsilk, color: t.color.text,
content: label, 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}`, key: `tr-${i}`,
label label
}) })
@ -827,12 +827,12 @@ export const ToolTrail = memo(function ToolTrail({
if (line === 'analyzing tool output…') { if (line === 'analyzing tool output…') {
pushDetail({ pushDetail({
color: t.color.dim, color: t.color.muted,
dimColor: true, dimColor: true,
key: `tr-${i}`, key: `tr-${i}`,
content: groups.length ? ( content: groups.length ? (
<> <>
<Spinner color={t.color.amber} variant="think" /> {line} <Spinner color={t.color.accent} variant="think" /> {line}
</> </>
) : ( ) : (
line line
@ -842,20 +842,20 @@ export const ToolTrail = memo(function ToolTrail({
continue 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) { for (const tool of tools) {
const label = formatToolCall(tool.name, tool.context || '') const label = formatToolCall(tool.name, tool.context || '')
groups.push({ groups.push({
color: t.color.cornsilk, color: t.color.text,
key: tool.id, key: tool.id,
label, label,
details: [], details: [],
content: ( content: (
<> <>
<Spinner color={t.color.amber} variant="tool" /> {label} <Spinner color={t.color.accent} variant="tool" /> {label}
{tool.startedAt ? ` (${fmtElapsed(now - tool.startedAt)})` : ''} {tool.startedAt ? ` (${fmtElapsed(now - tool.startedAt)})` : ''}
</> </>
) )
@ -864,7 +864,7 @@ export const ToolTrail = memo(function ToolTrail({
for (const item of activity.slice(-4)) { for (const item of activity.slice(-4)) {
const glyph = item.tone === 'error' ? '✗' : item.tone === 'warn' ? '!' : '·' 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}` }) meta.push({ color, content: `${glyph} ${item.text}`, dimColor: item.tone === 'info', key: `a-${item.id}` })
} }
@ -998,14 +998,14 @@ export const ToolTrail = memo(function ToolTrail({
} }
}} }}
> >
<Text color={t.color.dim} dim={!thinkingLive}> <Text color={t.color.muted} dim={!thinkingLive}>
<Text color={t.color.amber}>{openThinking ? '▾ ' : '▸ '}</Text> <Text color={t.color.accent}>{openThinking ? '▾ ' : '▸ '}</Text>
{thinkingLive ? ( {thinkingLive ? (
<Text bold color={t.color.cornsilk}> <Text bold color={t.color.text}>
Thinking Thinking
</Text> </Text>
) : ( ) : (
<Text color={t.color.dim} dim> <Text color={t.color.muted} dim>
Thinking Thinking
</Text> </Text>
)} )}
@ -1068,7 +1068,7 @@ export const ToolTrail = memo(function ToolTrail({
color={group.color} color={group.color}
content={ content={
<> <>
<Text color={t.color.amber}> </Text> <Text color={t.color.accent}> </Text>
{toolLabel(group)} {toolLabel(group)}
</> </>
} }
@ -1182,7 +1182,7 @@ export const ToolTrail = memo(function ToolTrail({
color={t.color.statusFg} color={t.color.statusFg}
content={ content={
<> <>
<Text color={t.color.amber}>Σ </Text> <Text color={t.color.accent}>Σ </Text>
{totalTokensLabel} {totalTokensLabel}
</> </>
} }
@ -1192,7 +1192,7 @@ export const ToolTrail = memo(function ToolTrail({
) : null} ) : null}
{outcome ? ( {outcome ? (
<Box marginTop={1}> <Box marginTop={1}>
<Text color={t.color.dim} dim> <Text color={t.color.muted} dim>
· {outcome} · {outcome}
</Text> </Text>
</Box> </Box>

View file

@ -9,7 +9,7 @@ import type { TodoItem } from '../types.js'
const rowColor = (t: Theme, status: TodoItem['status']) => { const rowColor = (t: Theme, status: TodoItem['status']) => {
const tone = todoTone(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({ export const TodoPanel = memo(function TodoPanel({
@ -56,16 +56,16 @@ export const TodoPanel = memo(function TodoPanel({
return ( return (
<Box flexDirection="column" marginBottom={1}> <Box flexDirection="column" marginBottom={1}>
<Box onClick={handleToggle}> <Box onClick={handleToggle}>
<Text color={t.color.dim}> <Text color={t.color.muted}>
<Text color={t.color.amber}>{effectiveCollapsed ? '▸ ' : '▾ '}</Text> <Text color={t.color.accent}>{effectiveCollapsed ? '▸ ' : '▾ '}</Text>
<Text bold color={t.color.cornsilk}> <Text bold color={t.color.text}>
Todo Todo
</Text>{' '} </Text>{' '}
<Text color={t.color.statusFg} dim> <Text color={t.color.statusFg} dim>
({done}/{todos.length}) ({done}/{todos.length})
</Text> </Text>
{incomplete && pending > 0 && ( {incomplete && pending > 0 && (
<Text color={t.color.dim} dim> <Text color={t.color.muted} dim>
{' '} {' '}
· incomplete · {pending} still {pending === 1 ? 'pending' : 'pending/in_progress'} · incomplete · {pending} still {pending === 1 ? 'pending' : 'pending/in_progress'}
</Text> </Text>

View file

@ -2,8 +2,8 @@ import type { Theme } from '../theme.js'
import type { Role } from '../types.js' import type { Role } from '../types.js'
export const ROLE: Record<Role, (t: Theme) => { body: string; glyph: string; prefix: string }> = { 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 }), assistant: t => ({ body: t.color.text, glyph: t.brand.tool, prefix: t.color.border }),
system: t => ({ body: '', glyph: '·', prefix: t.color.dim }), system: t => ({ body: '', glyph: '·', prefix: t.color.muted }),
tool: t => ({ body: t.color.dim, glyph: '⚡', prefix: t.color.dim }), 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 }) user: t => ({ body: t.color.label, glyph: t.brand.prompt, prefix: t.color.label })
} }

View file

@ -1,4 +1,10 @@
#!/usr/bin/env -S node --max-old-space-size=8192 --expose-gc #!/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 type { FrameEvent } from '@hermes/ink'
import { GatewayClient } from './gatewayClient.js' import { GatewayClient } from './gatewayClient.js'

View file

@ -117,8 +117,18 @@ export class GatewayClient extends EventEmitter {
return return
} }
// Append the most recent gateway stderr/log lines to the timeout
// event so users can tell apart "wrong python", "missing dep",
// and "config parse failure" from one glance instead of having
// to dig through `/logs`. Capped to keep the activity feed
// readable on slow boots.
const stderrTail = this.getLogTail(20)
this.pushLog(`[startup] timed out waiting for gateway.ready (python=${python}, cwd=${cwd})`) this.pushLog(`[startup] timed out waiting for gateway.ready (python=${python}, cwd=${cwd})`)
this.publish({ type: 'gateway.start_timeout', payload: { cwd, python } }) this.publish({
type: 'gateway.start_timeout',
payload: { cwd, python, stderr_tail: stderrTail }
})
}, STARTUP_TIMEOUT_MS) }, STARTUP_TIMEOUT_MS)
this.proc = spawn(python, ['-m', 'tui_gateway.entry'], { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] }) this.proc = spawn(python, ['-m', 'tui_gateway.entry'], { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] })

View file

@ -53,15 +53,25 @@ export type CommandDispatchResponse =
export interface ConfigDisplayConfig { export interface ConfigDisplayConfig {
bell_on_complete?: boolean bell_on_complete?: boolean
busy_input_mode?: string
details_mode?: string details_mode?: string
inline_diffs?: boolean inline_diffs?: boolean
mouse_tracking?: boolean | null | number | string
sections?: Record<string, string> sections?: Record<string, string>
show_cost?: boolean show_cost?: boolean
show_reasoning?: boolean show_reasoning?: boolean
streaming?: boolean streaming?: boolean
thinking_mode?: string thinking_mode?: string
tui_auto_resume_recent?: boolean
tui_compact?: 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 tui_statusbar?: 'bottom' | 'off' | 'on' | 'top' | boolean
} }
@ -119,6 +129,13 @@ export interface SessionListResponse {
sessions?: SessionListItem[] sessions?: SessionListItem[]
} }
export interface SessionMostRecentResponse {
session_id?: null | string
source?: string
started_at?: number
title?: string
}
export interface SessionTitleResponse { export interface SessionTitleResponse {
pending?: boolean pending?: boolean
session_key?: string session_key?: string
@ -415,7 +432,11 @@ export type GatewayEvent =
| { payload?: { state?: 'idle' | 'listening' | 'transcribing' }; session_id?: string; type: 'voice.status' } | { 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?: { no_speech_limit?: boolean; text?: string }; session_id?: string; type: 'voice.transcript' }
| { payload: { line: string }; session_id?: string; type: 'gateway.stderr' } | { payload: { line: string }; session_id?: string; type: 'gateway.stderr' }
| { payload?: { cwd?: string; python?: 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?: { preview?: string }; session_id?: string; type: 'gateway.protocol_error' }
| { payload?: { text?: string }; session_id?: string; type: 'reasoning.delta' | 'reasoning.available' } | { payload?: { text?: string }; session_id?: string; type: 'reasoning.delta' | 'reasoning.available' }
| { payload: { name?: string; preview?: string }; session_id?: string; type: 'tool.progress' } | { payload: { name?: string; preview?: string }; session_id?: string; type: 'tool.progress' }

View file

@ -0,0 +1,35 @@
/**
* 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 {}

View file

@ -42,7 +42,13 @@ export const isCopyShortcut = (
ch: string, ch: string,
env: NodeJS.ProcessEnv = process.env env: NodeJS.ProcessEnv = process.env
): boolean => ): 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). * Voice recording toggle key (Ctrl+B).

View file

@ -80,7 +80,7 @@ export function highlightLine(line: string, lang: string, t: Theme): Token[] {
} }
if (spec.comment && line.trimStart().startsWith(spec.comment)) { if (spec.comment && line.trimStart().startsWith(spec.comment)) {
return [[t.color.dim, line]] return [[t.color.muted, line]]
} }
const tokens: Token[] = [] const tokens: Token[] = []
@ -97,11 +97,11 @@ export function highlightLine(line: string, lang: string, t: Theme): Token[] {
const ch = tok[0]! const ch = tok[0]!
if (ch === '"' || ch === "'" || ch === '`') { if (ch === '"' || ch === "'" || ch === '`') {
tokens.push([t.color.amber, tok]) tokens.push([t.color.accent, tok])
} else if (ch >= '0' && ch <= '9') { } else if (ch >= '0' && ch <= '9') {
tokens.push([t.color.cornsilk, tok]) tokens.push([t.color.text, tok])
} else if (spec.keywords.has(tok)) { } else if (spec.keywords.has(tok)) {
tokens.push([t.color.bronze, tok]) tokens.push([t.color.border, tok])
} else { } else {
tokens.push(['', tok]) tokens.push(['', tok])
} }

View file

@ -25,6 +25,7 @@ export type TerminalSetupResult = {
} }
const DEFAULT_FILE_OPS: FileOps = { copyFile, mkdir, readFile, writeFile } const DEFAULT_FILE_OPS: FileOps = { copyFile, mkdir, readFile, writeFile }
const COPY_SEQUENCE = '\u001b[99;13u'
const MULTILINE_SEQUENCE = '\\\r\n' const MULTILINE_SEQUENCE = '\\\r\n'
const TERMINAL_META: Record<SupportedTerminal, { appName: string; label: string }> = { 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' } 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', key: 'shift+enter',
command: 'workbench.action.terminal.sendSequence', 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 { export function detectVSCodeLikeTerminal(env: NodeJS.ProcessEnv = process.env): null | SupportedTerminal {
const askpass = env['VSCODE_GIT_ASKPASS_MAIN']?.toLowerCase() ?? '' 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 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> { async function backupFile(filePath: string, ops: FileOps): Promise<void> {
const stamp = new Date().toISOString().replace(/[:.]/g, '-') const stamp = new Date().toISOString().replace(/[:.]/g, '-')
await ops.copyFile(filePath, `${filePath}.backup.${stamp}`) await ops.copyFile(filePath, `${filePath}.backup.${stamp}`)
@ -240,10 +335,9 @@ export async function configureTerminalKeybindings(
} }
} }
const conflicts = TARGET_BINDINGS.filter(target => const targets = targetBindings(platform)
keybindings.some( const conflicts = targets.filter(target =>
existing => isKeybinding(existing) && existing.key === target.key && !sameBinding(existing, target) keybindings.some(existing => isKeybinding(existing) && bindingsConflict(existing, target))
)
) )
if (conflicts.length) { if (conflicts.length) {
@ -256,7 +350,7 @@ export async function configureTerminalKeybindings(
let added = 0 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)) const exists = keybindings.some(existing => isKeybinding(existing) && sameBinding(existing, target))
if (!exists) { if (!exists) {
@ -340,7 +434,7 @@ export async function shouldPromptForTerminalSetup(options?: {
return true return true
} }
return TARGET_BINDINGS.some( return targetBindings(platform).some(
target => !parsed.some(existing => isKeybinding(existing) && sameBinding(existing, target)) target => !parsed.some(existing => isKeybinding(existing) && sameBinding(existing, target))
) )
} catch { } catch {

View file

@ -1,9 +1,9 @@
export interface ThemeColors { export interface ThemeColors {
gold: string primary: string
amber: string accent: string
bronze: string border: string
cornsilk: string text: string
dim: string muted: string
completionBg: string completionBg: string
completionCurrentBg: string completionCurrentBg: string
@ -88,18 +88,26 @@ const BRAND: ThemeBrand = {
helpHeader: '(^_^)? Commands' 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 = { export const DARK_THEME: Theme = {
color: { color: {
gold: '#FFD700', primary: '#FFD700',
amber: '#FFBF00', accent: '#FFBF00',
bronze: '#CD7F32', border: '#CD7F32',
cornsilk: '#FFF8DC', text: '#FFF8DC',
muted: '#CC9B1F',
// Bumped from the old `#B8860B` darkgoldenrod (~53% luminance) which // Bumped from the old `#B8860B` darkgoldenrod (~53% luminance) which
// read as barely-visible on dark terminals for long body text. The // read as barely-visible on dark terminals for long body text. The
// new value sits ~60% luminance — readable without losing the "muted / // new value sits ~60% luminance — readable without losing the "muted /
// secondary" semantic. Field labels still use `label` (65%) which // secondary" semantic. Field labels still use `label` (65%) which
// stays brighter so hierarchy holds. // stays brighter so hierarchy holds.
dim: '#CC9B1F',
completionBg: '#FFFFFF', completionBg: '#FFFFFF',
completionCurrentBg: mix('#FFFFFF', '#FFBF00', 0.25), completionCurrentBg: mix('#FFFFFF', '#FFBF00', 0.25),
@ -141,11 +149,11 @@ export const DARK_THEME: Theme = {
// cleanly (#11300). // cleanly (#11300).
export const LIGHT_THEME: Theme = { export const LIGHT_THEME: Theme = {
color: { color: {
gold: '#8B6914', primary: '#8B6914',
amber: '#A0651C', accent: '#A0651C',
bronze: '#7A4F1F', border: '#7A4F1F',
cornsilk: '#3D2F13', text: '#3D2F13',
dim: '#7A5A0F', muted: '#7A5A0F',
completionBg: '#F5F5F5', completionBg: '#F5F5F5',
completionCurrentBg: mix('#F5F5F5', '#A0651C', 0.25), completionCurrentBg: mix('#F5F5F5', '#A0651C', 0.25),
@ -179,23 +187,129 @@ export const LIGHT_THEME: Theme = {
bannerHero: '' bannerHero: ''
} }
// Pick light vs dark. Explicit `HERMES_TUI_LIGHT` wins; otherwise sniff const TRUE_RE = /^(?:1|true|yes|on)$/
// `COLORFGBG` (set by XFCE Terminal, rxvt, Terminal.app, etc.) — last field is the const FALSE_RE = /^(?:0|false|no|off)$/
// background ANSI index; 7/15 are the "white" slots most light themes emit (#11300).
export function detectLightMode(env: NodeJS.ProcessEnv = process.env): boolean {
const explicit = (env.HERMES_TUI_LIGHT ?? '').trim().toLowerCase()
if (/^(?:1|true|yes|on)$/.test(explicit)) { // 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; 015 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 return true
} }
if (/^(?:0|false|no|off)$/.test(explicit)) { if (FALSE_RE.test(lightFlag)) {
return false 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 06 and 814 are the dark half of the 015 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 export const DEFAULT_THEME: Theme = detectLightMode() ? LIGHT_THEME : DARK_THEME
@ -213,19 +327,20 @@ export function fromSkin(
const d = DEFAULT_THEME const d = DEFAULT_THEME
const c = (k: string) => colors[k] const c = (k: string) => colors[k]
const amber = c('ui_accent') ?? c('banner_accent') ?? d.color.amber const accent = c('ui_accent') ?? c('banner_accent') ?? d.color.accent
const accent = c('banner_accent') ?? c('banner_title') ?? d.color.amber const bannerAccent = c('banner_accent') ?? c('banner_title') ?? d.color.accent
const dim = c('banner_dim') ?? d.color.dim const muted = c('banner_dim') ?? d.color.muted
const completionBg = c('completion_menu_bg') ?? d.color.completionBg
return { return {
color: { color: {
gold: c('banner_title') ?? d.color.gold, primary: c('ui_primary') ?? c('banner_title') ?? d.color.primary,
amber, accent,
bronze: c('banner_border') ?? d.color.bronze, border: c('ui_border') ?? c('banner_border') ?? d.color.border,
cornsilk: c('banner_text') ?? d.color.cornsilk, text: c('ui_text') ?? c('banner_text') ?? d.color.text,
dim, muted,
completionBg: c('completion_menu_bg') ?? '#FFFFFF', completionBg,
completionCurrentBg: c('completion_menu_current_bg') ?? mix('#FFFFFF', accent, 0.25), completionCurrentBg: c('completion_menu_current_bg') ?? mix(completionBg, bannerAccent, 0.25),
label: c('ui_label') ?? d.color.label, label: c('ui_label') ?? d.color.label,
ok: c('ui_ok') ?? d.color.ok, ok: c('ui_ok') ?? d.color.ok,
@ -233,8 +348,8 @@ export function fromSkin(
warn: c('ui_warn') ?? d.color.warn, warn: c('ui_warn') ?? d.color.warn,
prompt: c('prompt') ?? c('banner_text') ?? d.color.prompt, prompt: c('prompt') ?? c('banner_text') ?? d.color.prompt,
sessionLabel: c('session_label') ?? dim, sessionLabel: c('session_label') ?? muted,
sessionBorder: c('session_border') ?? dim, sessionBorder: c('session_border') ?? muted,
statusBg: d.color.statusBg, statusBg: d.color.statusBg,
statusFg: d.color.statusFg, statusFg: d.color.statusFg,
@ -254,7 +369,7 @@ export function fromSkin(
brand: { brand: {
name: branding.agent_name ?? d.brand.name, name: branding.agent_name ?? d.brand.name,
icon: d.brand.icon, 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, welcome: branding.welcome ?? d.brand.welcome,
goodbye: branding.goodbye ?? d.brand.goodbye, goodbye: branding.goodbye ?? d.brand.goodbye,
tool: toolPrefix || d.brand.tool, tool: toolPrefix || d.brand.tool,

View file

@ -145,6 +145,7 @@ declare module '@hermes/ink' {
readonly clearSelection: () => void readonly clearSelection: () => void
readonly hasSelection: () => boolean readonly hasSelection: () => boolean
readonly getState: () => unknown readonly getState: () => unknown
readonly version: () => number
readonly subscribe: (cb: () => void) => () => void readonly subscribe: (cb: () => void) => () => void
readonly shiftAnchor: (dRow: number, minRow: number, maxRow: number) => void readonly shiftAnchor: (dRow: number, minRow: number, maxRow: number) => void
readonly shiftSelection: (dRow: number, minRow: number, maxRow: number) => void readonly shiftSelection: (dRow: number, minRow: number, maxRow: number) => void

View file

@ -182,6 +182,161 @@ async def handle(event_type: str, context: dict):
}, timeout=5) }, 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 ### How It Works
1. On gateway startup, `HookRegistry.discover_and_load()` scans `~/.hermes/hooks/` 1. On gateway startup, `HookRegistry.discover_and_load()` scans `~/.hermes/hooks/`

View file

@ -95,7 +95,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.` | | `welcome` | Welcome message shown at CLI startup | `Welcome to Hermes Agent! Type your message or /help for commands.` |
| `goodbye` | Message shown on exit | `Goodbye! ⚕` | | `goodbye` | Message shown on exit | `Goodbye! ⚕` |
| `response_label` | Label on the response box header | ` ⚕ Hermes ` | | `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` | | `help_header` | Header text for the `/help` command output | `(^_^)? Available Commands` |
### Other top-level keys ### Other top-level keys
@ -167,7 +167,7 @@ branding:
welcome: "Welcome to My Agent! Type your message or /help for commands." welcome: "Welcome to My Agent! Type your message or /help for commands."
goodbye: "See you later! ⚡" goodbye: "See you later! ⚡"
response_label: " ⚡ My Agent " response_label: " ⚡ My Agent "
prompt_symbol: "⚡ " prompt_symbol: "⚡"
help_header: "(⚡) Available Commands" help_header: "(⚡) Available Commands"
tool_prefix: "┊" tool_prefix: "┊"