Compare commits

..

24 Commits

Author SHA1 Message Date
Brooklyn Nicholson
f45b844670 fix(tui): improve learning ledger scanability
Shrink the details panel share and simplify category row labels so the four learning lists are easier to scan.
2026-04-28 17:01:34 -05:00
Brooklyn Nicholson
2476beac3a feat(tui): split learning ledger into category panels
Stress the shared overlay grid with separate memories, skills, recalls, and connected panels plus a details panel navigated by arrow keys.
2026-04-28 17:01:34 -05:00
Brooklyn Nicholson
8a0498d41e fix(tui): reserve overlay panel footers
Let overlay grid panels define footer content outside the clipped body so hints and pager controls stay visible under height caps.
2026-04-28 17:01:34 -05:00
Brooklyn Nicholson
5f749667e2 fix(tui): cap overlay grid height
Give floating overlay panels a shared terminal-derived height cap so long details or pager content clips inside the modal instead of expanding the whole overlay upward.
2026-04-28 17:01:34 -05:00
Brooklyn Nicholson
ee2cc327cb refactor(tui): use shared overlay grid
Replace bespoke floating boxes with a shared panel grid renderer so overlays and slash completions use stable widths, gaps, and panel ratios.
2026-04-28 17:01:34 -05:00
Brooklyn Nicholson
e004e1e5e4 fix(tui): simplify learning ledger panes
Drop nested borders from the learning ledger grid so the single floating shell frames the list/details layout without visual clutter.
2026-04-28 17:01:34 -05:00
Brooklyn Nicholson
2fe2d943b1 fix(tui): let learned overlay use full shell width
Remove the max-width cap from floating overlays and pass the full shell width into the learning ledger grid.
2026-04-28 17:01:34 -05:00
Brooklyn Nicholson
9a4bc5508a fix(tui): render learning ledger as grid panes
Make the learned overlay a real two-cell grid with a bordered 70% list pane, fixed gap, and bordered 30% details pane.
2026-04-28 17:01:34 -05:00
Brooklyn Nicholson
5f50f3df0d fix(tui): prevent learning ledger detail overlap
Pass the fixed floating overlay width into the learning ledger and reserve an explicit 70/30 master-detail split when details are open.
2026-04-28 17:01:34 -05:00
Brooklyn Nicholson
4821b50cfe fix(tui): stabilize floating overlay widths
Give shared floating overlays a stable terminal-derived width and split slash completions into fixed name/meta columns so popups stop resizing around content.
2026-04-28 17:01:34 -05:00
Brooklyn Nicholson
5bf688a30b fix(skins): make prompt symbols replace chevrons
Store built-in skin prompt symbols as the actual replacement glyph and let CLI/TUI prompt renderers own spacing.
2026-04-28 17:01:34 -05:00
Brooklyn Nicholson
d3cb027e17 fix(tui): stabilize skin prompt width
Normalize skin prompt symbols to trimmed single-line text and measure the active prompt width so wide skin glyphs do not wrap or distort the composer.
2026-04-28 17:01:34 -05:00
Brooklyn Nicholson
b0c84756ba fix(tui): keep memory tool previews one-line
Avoid malformed multi-line memory tool labels when the model omits a target by keeping the add preview compact and quote-adjacent.
2026-04-28 17:00:37 -05:00
Brooklyn Nicholson
bb5c3c1074 refactor(tui): migrate components to semantic theme tokens
Move regular TUI surfaces from palette-specific color names to semantic primary/accent/border/muted/text tokens, leaving raw color values centralized in theme.ts.
2026-04-28 17:00:37 -05:00
Brooklyn Nicholson
185e8ee942 refactor(tui): use semantic theme text colors
Replace decorative/base palette usage in TUI components with semantic theme text tokens and remove hardcoded overlay colors from FPS and heart indicators.
2026-04-28 17:00:04 -05:00
Brooklyn Nicholson
024cccb9bc fix(tui): dim learning note color
Use the active theme dim color for learning notes so they stay subtle on dark skins instead of inheriting the bright cornsilk text color.
2026-04-28 17:00:04 -05:00
Brooklyn Nicholson
15115808b1 fix(tui): render learning notes as standalone rows
Give learning ledger notes their own post-turn row instead of routing them through the normal system-message prefix.
2026-04-28 17:00:04 -05:00
Brooklyn Nicholson
c8a9e1234f fix(tui): include learning notes in turn completion
Carry learning events on the message completion payload so remembered/recalled notes flush deterministically after the assistant response even if standalone event timing is missed.
2026-04-28 17:00:04 -05:00
Brooklyn Nicholson
14af4ce665 fix(tui): place learning notes after responses
Buffer live learning events until the turn completes so remembered/recalled notes appear after the assistant response, and trim redundant user prefixes from memory titles.
2026-04-28 17:00:04 -05:00
Brooklyn Nicholson
9ee36e0732 feat(tui): surface live learning events
Emit learning events from memory, recall, and skill tool completions, render them as subtle italic transcript lines, and show learning stats/provenance in the TUI.
2026-04-28 17:00:04 -05:00
Brooklyn Nicholson
8e6f560fd3 refactor(tui): make learning ledger master-detail
Keep recent learning entries in a left-hand list and show the selected item details in a right-side pane only when expanded.
2026-04-28 17:00:04 -05:00
Brooklyn Nicholson
281b5ca546 refactor(tui): widen learning ledger layout
Use a wider floating ledger with two-column rows on large terminals while preserving the compact overlay behavior.
2026-04-28 17:00:04 -05:00
Brooklyn Nicholson
f2a08f7581 refactor(tui): focus learning ledger on recent growth
Keep installed skills as quiet inventory while promoting remembered facts, recalled sessions, reused skills, and connected integrations as the primary ledger rows.
2026-04-28 17:00:04 -05:00
Brooklyn Nicholson
61dc679815 feat(tui): add learning ledger overlay
Surface existing memories, skills, recalls, and integrations as a read-only growth ledger so Hermes' accumulated context is visible without changing agent behavior.
2026-04-28 17:00:04 -05:00
48 changed files with 1399 additions and 1126 deletions

View File

@@ -223,7 +223,8 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -
target = args.get("target", "")
if action == "add":
content = _oneline(args.get("content", ""))
return f"+{target}: \"{content[:25]}{'...' if len(content) > 25 else ''}\""
target_prefix = f"+{target}: " if target else "+"
return f"{target_prefix}\"{content[:25]}{'...' if len(content) > 25 else ''}\""
elif action == "replace":
old = _oneline(args.get("old_text") or "") or "<missing old_text>"
return f"~{target}: \"{old[:20]}\""

View File

@@ -0,0 +1,333 @@
"""Learning ledger: read-only index of how Hermes has grown for this profile."""
from __future__ import annotations
import json
import time
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
from hermes_constants import get_hermes_home
@dataclass
class LedgerItem:
type: str
name: str
summary: str
source: str
count: int = 0
learned_from: str | None = None
last_used_at: float | None = None
learned_at: float | None = None
via: str | None = None
def build_learning_ledger(db: Any = None, *, limit: int = 80) -> dict[str, Any]:
"""Build a compact, read-only ledger from existing Hermes artifacts."""
skill_inventory = _skill_inventory()
items = [
*_memory_items(),
*_tool_usage_items(db),
*_integration_items(),
]
items.sort(
key=lambda i: (i.last_used_at or i.learned_at or 0, i.type, i.name),
reverse=True,
)
counts: dict[str, int] = {}
for item in items:
counts[item.type] = counts.get(item.type, 0) + 1
return {
"generated_at": time.time(),
"home": str(get_hermes_home()),
"counts": counts,
"items": [asdict(item) for item in items[: max(1, limit)]],
"inventory": {"skills": skill_inventory},
"total": len(items),
}
def _memory_items() -> list[LedgerItem]:
try:
from tools.memory_tool import MemoryStore, get_memory_dir
mem_dir = get_memory_dir()
pairs = [
("memory", "MEMORY.md", "agent note"),
("user", "USER.md", "user profile"),
]
items: list[LedgerItem] = []
for item_type, filename, label in pairs:
path = mem_dir / filename
for idx, entry in enumerate(MemoryStore._read_file(path), 1):
items.append(
LedgerItem(
type=item_type,
name=f"{label} {idx}",
summary=_one_line(entry),
source=str(path),
learned_at=_mtime(path),
)
)
return items
except Exception:
return []
def _skill_inventory() -> int:
try:
from tools.skills_tool import _find_all_skills
return len(_find_all_skills())
except Exception:
return 0
def _tool_usage_items(db: Any) -> list[LedgerItem]:
if db is None or not getattr(db, "_conn", None):
return []
usage: dict[tuple[str, str], LedgerItem] = {}
def bump(
item_type: str,
name: str,
summary: str,
ts: float | None,
*,
learned_from: str | None = None,
via: str | None = None,
):
key = (item_type, name)
item = usage.get(key)
if not item:
item = usage[key] = LedgerItem(
type=item_type,
name=name,
summary=summary,
source="state.db",
learned_from=learned_from,
via=via,
)
item.count += 1
if ts and (not item.last_used_at or ts > item.last_used_at):
item.last_used_at = ts
item.learned_from = learned_from or item.learned_from
item.via = via or item.via
try:
with db._lock:
rows = db._conn.execute(
"""
SELECT m.role, m.content, m.tool_calls, m.tool_name, m.timestamp,
m.session_id, s.title, s.source AS session_source
FROM messages m
LEFT JOIN sessions s ON s.id = m.session_id
WHERE m.tool_name IS NOT NULL OR m.tool_calls IS NOT NULL
ORDER BY m.timestamp DESC
LIMIT 5000
"""
).fetchall()
except Exception:
return []
for row in rows:
ts = _float(row["timestamp"])
tool_name = row["tool_name"]
content = row["content"] or ""
learned_from = row["title"] or row["session_source"] or row["session_id"]
if tool_name == "memory":
target = _json(content).get("target") or "memory"
bump(str(target), f"{target} writes", "Durable memory updates", ts, learned_from=learned_from, via="memory")
elif tool_name == "session_search":
event = learning_event_from_tool(tool_name, {}, content)
if event:
bump("recall", event["title"], event["summary"], ts, learned_from=learned_from, via="session_search")
elif tool_name in {"skill_view", "skill_manage"}:
data = _json(content)
name = str(data.get("name") or data.get("skill") or tool_name)
bump("skill-use", name, _skill_summary(tool_name, data), ts, learned_from=learned_from, via=tool_name)
for call in _tool_calls(row["tool_calls"]):
name, args = call
if name == "session_search":
event = learning_event_from_tool(name, args, content)
if event:
bump("recall", event["title"], event["summary"], ts, learned_from=learned_from, via=name)
elif name in {"skill_view", "skill_manage"}:
skill_name = str(
args.get("name") or args.get("skill") or args.get("query") or name
)
bump("skill-use", skill_name, _skill_summary(name, args), ts, learned_from=learned_from, via=name)
elif name == "memory":
target = str(args.get("target") or "memory")
bump(target, f"{target} writes", "Durable memory updates", ts, learned_from=learned_from, via=name)
return list(usage.values())
def learning_event_from_tool(
tool_name: str,
args: dict[str, Any] | None = None,
result: str | None = None,
) -> dict[str, Any] | None:
args = args or {}
data = _json(result)
if tool_name == "memory":
target = str(args.get("target") or data.get("target") or "memory")
content = str(args.get("content") or "").strip()
return {
"type": target if target in {"memory", "user"} else "memory",
"verb": "remembered",
"title": _memory_title(content) if content else f"{target} updated",
"summary": "Durable memory updated",
"source": "memory",
"via": "memory",
}
if tool_name == "session_search":
title = _recall_title(data) or str(args.get("query") or "").strip() or "past sessions"
return {
"type": "recall",
"verb": "recalled",
"title": _one_line(title, max_len=120),
"summary": "Past conversations recalled",
"source": "state.db",
"via": "session_search",
}
if tool_name in {"skill_view", "skill_manage"}:
action = str(args.get("action") or data.get("action") or "").strip().lower()
name = str(args.get("name") or args.get("query") or data.get("name") or "skill").strip()
verb = "updated skill" if tool_name == "skill_manage" and action in {"create", "patch", "update", "install"} else "applied skill"
return {
"type": "skill-use",
"verb": verb,
"title": _one_line(name, max_len=120),
"summary": _skill_summary(tool_name, {**args, **(data if isinstance(data, dict) else {})}),
"source": "skills",
"via": tool_name,
}
return None
def _skill_summary(tool_name: str, data: dict[str, Any]) -> str:
action = str(data.get("action") or "").strip().lower()
if tool_name == "skill_manage" and action:
return f"Skill {action.replace('_', ' ')}"
if tool_name == "skill_manage":
return "Skill managed"
return "Skill reused"
def _recall_title(data: Any) -> str:
if not isinstance(data, dict):
return ""
results = data.get("results")
if not isinstance(results, list) or not results:
return str(data.get("query") or "").strip()
first = results[0] if isinstance(results[0], dict) else {}
return str(first.get("title") or first.get("preview") or data.get("query") or "").strip()
def _memory_title(content: str) -> str:
title = _one_line(content, max_len=120)
lowered = title.lower()
for prefix in ("the user ", "user "):
if lowered.startswith(prefix):
return title[len(prefix):].lstrip()
return title
def _integration_items() -> list[LedgerItem]:
try:
from hermes_cli.config import load_config
cfg = load_config()
except Exception:
return []
items: list[LedgerItem] = []
provider = ((cfg.get("memory") or {}) if isinstance(cfg, dict) else {}).get(
"provider"
)
if provider:
items.append(
LedgerItem(
type="integration",
name=f"{provider} memory provider",
summary="External memory provider is configured",
source="config.yaml",
)
)
for server in (
sorted(((cfg.get("mcp") or {}).get("servers") or {}).keys())
if isinstance(cfg, dict)
else []
):
items.append(
LedgerItem(
type="integration",
name=f"{server} MCP server",
summary="MCP server is configured",
source="config.yaml",
)
)
return items
def _tool_calls(raw: str | None) -> list[tuple[str, dict[str, Any]]]:
calls = _json(raw)
if not isinstance(calls, list):
return []
parsed = []
for call in calls:
if not isinstance(call, dict):
continue
fn = call.get("function") or {}
name = call.get("name") or fn.get("name")
args = fn.get("arguments") or call.get("arguments") or call.get("args") or {}
if isinstance(args, str):
args = _json(args)
if name:
parsed.append((str(name), args if isinstance(args, dict) else {}))
return parsed
def _json(raw: Any) -> Any:
if not raw:
return {}
if isinstance(raw, (dict, list)):
return raw
try:
return json.loads(raw)
except Exception:
return {}
def _mtime(path: Path) -> float | None:
try:
return path.stat().st_mtime
except OSError:
return None
def _float(value: Any) -> float | None:
try:
return float(value)
except (TypeError, ValueError):
return None
def _one_line(text: str, *, max_len: int = 180) -> str:
line = " ".join(str(text).split())
return line[: max_len - 1] + "" if len(line) > max_len else line

View File

@@ -68,7 +68,7 @@ All fields are optional. Missing values inherit from the ``default`` skin.
welcome: "Welcome message" # Shown at CLI startup
goodbye: "Goodbye! ⚕" # Shown on exit
response_label: " ⚕ Hermes " # Response box header label
prompt_symbol: " " # Input prompt symbol
prompt_symbol: "" # Input prompt symbol (spacing is added by the UI)
help_header: "(^_^)? Commands" # /help header text
# 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.",
"goodbye": "Goodbye! ⚕",
"response_label": " ⚕ Hermes ",
"prompt_symbol": " ",
"prompt_symbol": "",
"help_header": "(^_^)? Available Commands",
},
"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.",
"goodbye": "Farewell, warrior! ⚔",
"response_label": " ⚔ Ares ",
"prompt_symbol": " ",
"prompt_symbol": "",
"help_header": "(⚔) Available Commands",
},
"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.",
"goodbye": "Goodbye! ⚕",
"response_label": " ⚕ Hermes ",
"prompt_symbol": " ",
"prompt_symbol": "",
"help_header": "[?] Available Commands",
},
"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.",
"goodbye": "Goodbye! ⚕",
"response_label": " ⚕ Hermes ",
"prompt_symbol": " ",
"prompt_symbol": "",
"help_header": "(^_^)? Available Commands",
},
"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.",
"goodbye": "Goodbye! ⚕",
"response_label": " ⚕ Hermes ",
"prompt_symbol": " ",
"prompt_symbol": "",
"help_header": "[?] Available Commands",
},
"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.",
"goodbye": "Goodbye! \u2695",
"response_label": " \u2695 Hermes ",
"prompt_symbol": "\u276f ",
"prompt_symbol": "\u276f",
"help_header": "(^_^)? Available Commands",
},
"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.",
"goodbye": "Fair winds! Ψ",
"response_label": " Ψ Poseidon ",
"prompt_symbol": "Ψ ",
"prompt_symbol": "Ψ",
"help_header": "(Ψ) Available Commands",
},
"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.",
"goodbye": "The boulder waits. ◉",
"response_label": " ◉ Sisyphus ",
"prompt_symbol": " ",
"prompt_symbol": "",
"help_header": "(◉) Available Commands",
},
"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.",
"goodbye": "Flame out! ✦",
"response_label": " ✦ Charizard ",
"prompt_symbol": " ",
"prompt_symbol": "",
"help_header": "(✦) Available Commands",
},
"tool_prefix": "",

View File

@@ -82,6 +82,11 @@ class TestBuildToolPreview:
result = build_tool_preview("memory", {"action": "add", "target": "user", "content": "test note"})
assert result is not None
assert "user" in result
assert "\n" not in result
def test_memory_tool_add_without_target_stays_one_line(self):
result = build_tool_preview("memory", {"action": "add", "content": "User identifies as a cutie patootie."})
assert result == '+"User identifies as a cuti..."'
def test_memory_replace_missing_old_text_marked(self):
# Avoid empty quotes "" in the preview when old_text is missing/None.

View File

@@ -2721,330 +2721,3 @@ def test_session_most_recent_handles_db_unavailable(monkeypatch):
)
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
# ── reload.env ───────────────────────────────────────────────────────
def test_reload_env_rpc_calls_hermes_cli_reload_env(monkeypatch):
"""reload.env mirrors classic CLI's `/reload` — re-reads ~/.hermes/.env
into the gateway process and reports the count of vars updated."""
calls = {"n": 0}
def _fake_reload():
calls["n"] += 1
return 7
fake = types.SimpleNamespace(reload_env=_fake_reload)
with patch.dict(sys.modules, {"hermes_cli.config": fake}):
resp = server.handle_request(
{"id": "1", "method": "reload.env", "params": {}}
)
assert resp["result"] == {"updated": 7}
assert calls["n"] == 1
def test_reload_env_rpc_surfaces_errors(monkeypatch):
def _broken():
raise RuntimeError("env path locked")
fake = types.SimpleNamespace(reload_env=_broken)
with patch.dict(sys.modules, {"hermes_cli.config": fake}):
resp = server.handle_request(
{"id": "1", "method": "reload.env", "params": {}}
)
assert "error" in resp
assert "env path locked" in resp["error"]["message"]

View File

@@ -1023,6 +1023,17 @@ def _session_info(agent) -> dict:
info["mcp_servers"] = get_mcp_status()
except Exception:
info["mcp_servers"] = []
try:
from hermes_cli.learning_ledger import build_learning_ledger
ledger = build_learning_ledger(_get_db(), limit=1)
info["learning"] = {
"counts": ledger.get("counts", {}),
"inventory": ledger.get("inventory", {}),
"total": ledger.get("total", 0),
}
except Exception:
pass
try:
from hermes_cli.banner import get_update_result
from hermes_cli.config import recommended_update_command
@@ -1145,6 +1156,16 @@ def _on_tool_complete(sid: str, tool_call_id: str, name: str, args: dict, result
pass
if _tool_progress_enabled(sid) or payload.get("inline_diff"):
_emit("tool.complete", sid, payload)
try:
from hermes_cli.learning_ledger import learning_event_from_tool
event = learning_event_from_tool(name, args, result)
if event:
if session is not None:
session.setdefault("learning_events", []).append(event)
_emit("learning.event", sid, event)
except Exception:
pass
def _on_tool_progress(
@@ -2421,6 +2442,7 @@ def _(rid, params: dict) -> dict:
if session.get("running"):
return _err(rid, 4009, "session busy")
session["running"] = True
session["learning_events"] = []
history = list(session["history"])
history_version = int(session.get("history_version", 0))
images = list(session.get("attached_images", []))
@@ -2584,6 +2606,9 @@ def _(rid, params: dict) -> dict:
payload["reasoning"] = last_reasoning
if status_note:
payload["warning"] = status_note
learning_events = list(session.get("learning_events") or [])
if learning_events:
payload["learning_events"] = learning_events
rendered = render_message(raw, cols)
if rendered:
payload["rendered"] = rendered
@@ -3383,27 +3408,6 @@ def _(rid, params: dict) -> dict:
return _err(rid, 5015, str(e))
@method("reload.env")
def _(rid, params: dict) -> dict:
"""Re-read ``~/.hermes/.env`` into the gateway process via
``hermes_cli.config.reload_env``, matching classic CLI's ``/reload``
handler. Newly added API keys take effect on the next agent call
without restarting the TUI.
The credential pool / provider routing for any *already-constructed*
agent does not auto-rebuild — that's the same behaviour as classic
CLI's ``/reload``. Users who want a brand-new credential resolution
should follow with ``/new``.
"""
try:
from hermes_cli.config import reload_env
count = reload_env()
return _ok(rid, {"updated": int(count)})
except Exception as e:
return _err(rid, 5015, str(e))
_TUI_HIDDEN: frozenset[str] = frozenset(
{
"sethome",
@@ -4695,51 +4699,12 @@ def _(rid, params: dict) -> dict:
# ── 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")
def _(rid, params: dict) -> dict:
action = params.get("action", "status")
if action == "status":
resolved_url = _resolve_browser_cdp_url()
return _ok(
rid,
{
"connected": bool(resolved_url),
"url": resolved_url,
},
)
url = os.environ.get("BROWSER_CDP_URL", "")
return _ok(rid, {"connected": bool(url), "url": url})
if action == "connect":
url = params.get("url", "http://localhost:9222")
try:
@@ -4750,97 +4715,36 @@ def _(rid, params: dict) -> dict:
parsed = urlparse(url if "://" in url else f"http://{url}")
if parsed.scheme not in {"http", "https", "ws", "wss"}:
return _err(rid, 4015, f"unsupported browser 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}")
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 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}")
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``.
os.environ["BROWSER_CDP_URL"] = url
cleanup_all_browsers()
except Exception as e:
return _err(rid, 5031, str(e))
return _ok(rid, {"connected": True, "url": normalized})
return _ok(rid, {"connected": True, "url": url})
if action == "disconnect":
os.environ.pop("BROWSER_CDP_URL", None)
try:
from tools.browser_tool import cleanup_all_browsers
cleanup_all_browsers()
except Exception:
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 _err(rid, 4015, f"unknown action: {action}")
@@ -5186,6 +5090,22 @@ def _(rid, params: dict) -> dict:
return _err(rid, 5024, str(e))
@method("learning.ledger")
def _(rid, params: dict) -> dict:
try:
from hermes_cli.learning_ledger import build_learning_ledger
return _ok(
rid,
build_learning_ledger(
_get_db(),
limit=int(params.get("limit", 80) or 80),
),
)
except Exception as e:
return _err(rid, 5025, str(e))
# ── Methods: shell ───────────────────────────────────────────────────

View File

@@ -314,48 +314,6 @@ describe('createGatewayEventHandler', () => {
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', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))

View File

@@ -85,6 +85,15 @@ describe('createSlashHandler', () => {
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
})
it('opens the learning ledger locally', () => {
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/learned')).toBe(true)
expect(getOverlayState().learningLedger).toBe(true)
expect(ctx.gateway.rpc).not.toHaveBeenCalled()
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
})
it('routes /skills install <name> to skills.manage without opening overlay', () => {
const ctx = buildCtx()
@@ -193,7 +202,6 @@ describe('createSlashHandler', () => {
it.each([
['/browser status', 'browser.manage', { action: 'status' }],
['/reload-mcp', 'reload.mcp', { session_id: null }],
['/reload', 'reload.env', {}],
['/stop', 'process.stop', {}],
['/fast status', 'config.get', { key: 'fast', session_id: null }],
['/busy status', 'config.get', { key: 'busy' }]

View File

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

View File

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

View File

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

View File

@@ -10,16 +10,16 @@ describe('DEFAULT_THEME', () => {
})
it('has color palette', () => {
expect(DEFAULT_THEME.color.gold).toBe('#FFD700')
expect(DEFAULT_THEME.color.primary).toBe('#FFD700')
expect(DEFAULT_THEME.color.error).toBe('#ef5350')
})
})
describe('LIGHT_THEME', () => {
it('avoids bright-yellow accents unreadable on white backgrounds (#11300)', () => {
expect(LIGHT_THEME.color.gold).not.toBe('#FFD700')
expect(LIGHT_THEME.color.amber).not.toBe('#FFBF00')
expect(LIGHT_THEME.color.dim).not.toBe('#B8860B')
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')
})
@@ -63,11 +63,11 @@ describe('detectLightMode', () => {
describe('fromSkin', () => {
it('overrides banner colors', () => {
expect(fromSkin({ banner_title: '#FF0000' }, {}).color.gold).toBe('#FF0000')
expect(fromSkin({ banner_title: '#FF0000' }, {}).color.primary).toBe('#FF0000')
})
it('preserves unset colors', () => {
expect(fromSkin({ banner_title: '#FF0000' }, {}).color.amber).toBe(DEFAULT_THEME.color.amber)
expect(fromSkin({ banner_title: '#FF0000' }, {}).color.accent).toBe(DEFAULT_THEME.color.accent)
})
it('overrides branding', () => {
@@ -76,6 +76,11 @@ describe('fromSkin', () => {
expect(brand.prompt).toBe('$')
})
it('normalizes skin prompt symbols to one trimmed line', () => {
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', () => {
expect(fromSkin({}, {}).color).toEqual(DEFAULT_THEME.color)
expect(fromSkin({}, {}).brand.icon).toBe(DEFAULT_THEME.brand.icon)

View File

@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { $uiState, resetUiState } from '../app/uiStore.js'
import { applyDisplay, normalizeBusyInputMode, normalizeStatusBar } from '../app/useConfigSync.js'
import { applyDisplay, normalizeStatusBar } from '../app/useConfigSync.js'
describe('applyDisplay', () => {
beforeEach(() => {
@@ -160,55 +160,3 @@ describe('normalizeStatusBar', () => {
expect(normalizeStatusBar('OFF')).toBe('off')
})
})
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('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')
})
})

View File

@@ -64,6 +64,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
let pendingThinkingStatus = ''
let thinkingStatusTimer: null | ReturnType<typeof setTimeout> = null
let pendingLearning: string[] = []
// Inject the disk-save callback into turnController so recordMessageComplete
// can fire-and-forget a persist without having to plumb a gateway ref around.
@@ -269,7 +270,19 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
return
}
case 'learning.event': {
const title = String(ev.payload?.title ?? '').trim()
const verb = String(ev.payload?.verb ?? ev.payload?.type ?? 'learned').trim()
if (title) {
pendingLearning = pushUnique(4)(pendingLearning, `${verb}: ${title}`)
}
return
}
case 'message.start':
pendingLearning = []
turnController.startMessage()
return
@@ -590,10 +603,22 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
return
case 'message.complete': {
const { finalMessages, finalText, wasInterrupted } = turnController.recordMessageComplete(ev.payload ?? {})
const completedLearning = (ev.payload?.learning_events ?? [])
.map(e => {
const title = String(e?.title ?? '').trim()
const verb = String(e?.verb ?? e?.type ?? 'learned').trim()
return title ? `${verb}: ${title}` : ''
})
.filter(Boolean)
if (!wasInterrupted) {
const msgs: Msg[] = finalMessages.length ? finalMessages : [{ role: 'assistant', text: finalText }]
const learningLines = [...completedLearning, ...pendingLearning].filter((text, i, xs) => xs.indexOf(text) === i)
msgs.forEach(appendMessage)
learningLines.forEach(text => appendMessage({ kind: 'learning', role: 'system', text }))
pendingLearning = []
if (bellOnComplete && stdout?.isTTY) {
stdout.write('\x07')

View File

@@ -27,8 +27,6 @@ export interface StateSetter<T> {
export type StatusBarMode = 'bottom' | 'off' | 'top'
export type BusyInputMode = 'interrupt' | 'queue' | 'steer'
export interface SelectionApi {
captureScrolledRows: (firstRow: number, lastRow: number, side: 'above' | 'below') => void
clearSelection: () => void
@@ -64,6 +62,7 @@ export interface OverlayState {
approval: ApprovalReq | null
clarify: ClarifyReq | null
confirm: ConfirmReq | null
learningLedger: boolean
modelPicker: boolean
pager: null | PagerState
picker: boolean
@@ -87,7 +86,6 @@ export interface TranscriptRow {
export interface UiState {
bgTasks: Set<string>
busy: boolean
busyInputMode: BusyInputMode
compact: boolean
detailsMode: DetailsMode
detailsModeCommandOverride: boolean

View File

@@ -8,6 +8,7 @@ const buildOverlayState = (): OverlayState => ({
approval: null,
clarify: null,
confirm: null,
learningLedger: false,
modelPicker: false,
pager: null,
picker: false,
@@ -20,8 +21,20 @@ export const $overlayState = atom<OverlayState>(buildOverlayState())
export const $isBlocked = computed(
$overlayState,
({ agents, approval, clarify, confirm, modelPicker, pager, picker, secret, skillsHub, sudo }) =>
Boolean(agents || approval || clarify || confirm || modelPicker || pager || picker || secret || skillsHub || sudo)
({ agents, approval, clarify, confirm, learningLedger, modelPicker, pager, picker, secret, skillsHub, sudo }) =>
Boolean(
agents ||
approval ||
clarify ||
confirm ||
learningLedger ||
modelPicker ||
pager ||
picker ||
secret ||
skillsHub ||
sudo
)
)
export const getOverlayState = () => $overlayState.get()
@@ -45,6 +58,7 @@ export const resetFlowOverlays = () =>
...buildOverlayState(),
agents: $overlayState.get().agents,
agentsInitialHistoryIndex: $overlayState.get().agentsInitialHistoryIndex,
learningLedger: $overlayState.get().learningLedger,
modelPicker: $overlayState.get().modelPicker,
picker: $overlayState.get().picker,
skillsHub: $overlayState.get().skillsHub

View File

@@ -2,7 +2,6 @@ import type {
BrowserManageResponse,
DelegationPauseResponse,
ProcessStopResponse,
ReloadEnvResponse,
ReloadMcpResponse,
RollbackDiffResponse,
RollbackListResponse,
@@ -90,24 +89,6 @@ export const opsCommands: SlashCommand[] = [
}
},
{
help: 're-read ~/.hermes/.env into the running gateway (CLI parity)',
name: 'reload',
run: (_arg, ctx) => {
ctx.gateway
.rpc<ReloadEnvResponse>('reload.env', {})
.then(
ctx.guarded<ReloadEnvResponse>(r => {
const n = Number(r.updated ?? 0)
const noun = n === 1 ? 'var' : 'vars'
ctx.transcript.sys(`reloaded .env (${n} ${noun} updated)`)
})
)
.catch(ctx.guardedErr)
}
},
{
help: 'manage browser CDP connection [connect|disconnect|status]',
name: 'browser',
@@ -117,16 +98,13 @@ export const opsCommands: SlashCommand[] = [
const action = (rawAction || 'status').toLowerCase()
if (!['connect', 'disconnect', 'status'].includes(action)) {
return ctx.transcript.sys(
'usage: /browser [connect|disconnect|status] [url] · persistent: set browser.cdp_url in config.yaml'
)
return ctx.transcript.sys('usage: /browser [connect|disconnect|status] [url]')
}
const payload: Record<string, unknown> = { action }
const requested = rest.join(' ').trim()
if (action === 'connect') {
payload.url = requested || 'http://localhost:9222'
payload.url = rest.join(' ').trim() || 'http://localhost:9222'
}
ctx.gateway
@@ -135,21 +113,14 @@ export const opsCommands: SlashCommand[] = [
ctx.guarded<BrowserManageResponse>(r => {
if (action === 'status') {
return ctx.transcript.sys(
r.connected
? `browser connected: ${r.url || '(url unavailable)'}`
: 'browser not connected (try /browser connect <url> or set browser.cdp_url in config.yaml)'
r.connected ? `browser connected: ${r.url || '(url unavailable)'}` : 'browser not connected'
)
}
if (action === 'connect') {
if (r.connected) {
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')
return ctx.transcript.sys(
r.connected ? `browser connected: ${r.url || '(url unavailable)'}` : 'browser connect failed'
)
}
ctx.transcript.sys('browser disconnected')
@@ -408,6 +379,13 @@ export const opsCommands: SlashCommand[] = [
}
},
{
aliases: ['growth', 'learned'],
help: 'show memories, skills, recalls, and integrations Hermes has accumulated',
name: 'learning',
run: () => patchOverlayState({ learningLedger: true })
},
{
help: 'browse, inspect, install skills',
name: 'skills',

View File

@@ -431,13 +431,7 @@ class TurnController {
recordMessageComplete(payload: { rendered?: string; reasoning?: string; text?: string }) {
this.closeReasoningSegment()
// 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 rawText = (payload.rendered ?? payload.text ?? this.bufRef).trimStart()
const split = splitReasoning(rawText)
const finalText = finalTail(split.text, this.segmentMessages)
const existingReasoning = this.reasoningText.trim() || String(payload.reasoning ?? '').trim()
@@ -522,7 +516,7 @@ class TurnController {
return { finalMessages, finalText, wasInterrupted }
}
recordMessageDelta({ text }: { rendered?: string; text?: string }) {
recordMessageDelta({ rendered, text }: { rendered?: string; text?: string }) {
if (this.interrupted || !text) {
return
}
@@ -530,12 +524,7 @@ class TurnController {
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
this.bufRef = rendered ?? this.bufRef + text
if (getUiState().streaming) {
this.scheduleStreaming()

View File

@@ -9,7 +9,6 @@ import type { UiState } from './interfaces.js'
const buildUiState = (): UiState => ({
bgTasks: new Set(),
busy: false,
busyInputMode: 'queue',
compact: false,
detailsMode: 'collapsed',
detailsModeCommandOverride: false,

View File

@@ -10,7 +10,7 @@ import type {
} from '../gatewayTypes.js'
import { asRpcResult } from '../lib/rpc.js'
import type { BusyInputMode, StatusBarMode } from './interfaces.js'
import type { StatusBarMode } from './interfaces.js'
import { turnController } from './turnController.js'
import { patchUiState } from './uiStore.js'
@@ -24,27 +24,6 @@ const STATUSBAR_ALIAS: Record<string, StatusBarMode> = {
export const normalizeStatusBar = (raw: unknown): StatusBarMode =>
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 MTIME_POLL_MS = 5000
const quietRpc = async <T extends Record<string, any> = Record<string, any>>(
@@ -64,7 +43,6 @@ export const applyDisplay = (cfg: ConfigFullResponse | null, setBell: (v: boolea
setBell(!!d.bell_on_complete)
patchUiState({
busyInputMode: normalizeBusyInputMode(d.busy_input_mode),
compact: !!d.tui_compact,
detailsMode: resolveDetailsMode(d),
detailsModeCommandOverride: false,

View File

@@ -92,6 +92,10 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
return patchOverlayState({ skillsHub: false })
}
if (overlay.learningLedger) {
return patchOverlayState({ learningLedger: false })
}
if (overlay.picker) {
return patchOverlayState({ picker: false })
}

View File

@@ -52,7 +52,7 @@ const capHistory = (items: Msg[]): Msg[] => {
return items[0]?.kind === 'intro' ? [items[0]!, ...items.slice(-(MAX_HISTORY - 1))] : items.slice(-MAX_HISTORY)
}
const statusColorOf = (status: string, t: { dim: string; error: string; ok: string; warn: string }) => {
const statusColorOf = (status: string, t: { error: string; muted: string; ok: string; warn: string }) => {
if (status === 'ready') {
return t.ok
}
@@ -65,7 +65,7 @@ const statusColorOf = (status: string, t: { dim: string; error: string; ok: stri
return t.warn
}
return t.dim
return t.muted
}
export function useMainApp(gw: GatewayClient) {

View File

@@ -4,12 +4,7 @@ import { TYPING_IDLE_MS } from '../config/timing.js'
import { attachedImageNotice } from '../domain/messages.js'
import { looksLikeSlashCommand } from '../domain/slash.js'
import type { GatewayClient } from '../gatewayClient.js'
import type {
InputDetectDropResponse,
PromptSubmitResponse,
SessionSteerResponse,
ShellExecResponse
} from '../gatewayTypes.js'
import type { InputDetectDropResponse, PromptSubmitResponse, ShellExecResponse } from '../gatewayTypes.js'
import { asRpcResult } from '../lib/rpc.js'
import { hasInterpolation, INTERPOLATION_RE } from '../protocol/interpolation.js'
import { PASTE_SNIPPET_RE } from '../protocol/paste.js'
@@ -212,70 +207,6 @@ export function useSubmission(opts: UseSubmissionOptions) {
[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(
(full: string) => {
if (!full.trim()) {
@@ -321,16 +252,9 @@ export function useSubmission(opts: UseSubmissionOptions) {
}
if (getUiState().busy) {
// '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)
composerRefs.queueRef.current.unshift(picked)
return composerActions.syncQueue()
}
return handleBusyInput(picked, { fallbackToFront: true })
return composerActions.syncQueue()
}
return sendQueued(picked)
@@ -339,7 +263,7 @@ export function useSubmission(opts: UseSubmissionOptions) {
composerActions.pushHistory(full)
if (getUiState().busy) {
return handleBusyInput(full)
return composerActions.enqueue(full)
}
if (hasInterpolation(full)) {
@@ -350,17 +274,7 @@ export function useSubmission(opts: UseSubmissionOptions) {
send(full)
},
[
appendMessage,
composerActions,
composerRefs,
handleBusyInput,
interpolate,
send,
sendQueued,
shellExec,
slashRef
]
[appendMessage, composerActions, composerRefs, interpolate, send, sendQueued, shellExec, slashRef]
)
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 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

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

View File

@@ -1,6 +1,6 @@
import { Box, type ScrollBoxHandle, Text } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import { type ReactNode, type RefObject, useEffect, useMemo, useState } from 'react'
import { type RefObject, useEffect, useMemo, useState } from 'react'
import { $delegationState } from '../app/delegationStore.js'
import { useTurnSelector } from '../app/turnStore.js'
@@ -15,8 +15,6 @@ import type { Theme } from '../theme.js'
import type { Msg, Usage } from '../types.js'
const FACE_TICK_MS = 2500
const HEART_COLORS = ['#ff5fa2', '#ff4d6d']
function FaceTicker({ color, startedAt }: { color: string; startedAt?: null | number }) {
const [tick, setTick] = useState(() => Math.floor(Math.random() * 1000))
const [now, setNow] = useState(() => Date.now())
@@ -40,7 +38,7 @@ function FaceTicker({ color, startedAt }: { color: string; startedAt?: null | nu
function ctxBarColor(pct: number | undefined, t: Theme) {
if (pct == null) {
return t.color.dim
return t.color.muted
}
if (pct >= 95) {
@@ -93,7 +91,7 @@ function SpawnHud({ t }: { t: Theme }) {
const concRatio = maxConc ? widestLevel / maxConc : 0
const ratio = Math.max(depthRatio, concRatio)
const color = delegation.paused || ratio >= 1 ? t.color.error : ratio >= 0.66 ? t.color.warn : t.color.dim
const color = delegation.paused || ratio >= 1 ? t.color.error : ratio >= 0.66 ? t.color.warn : t.color.muted
const pieces: string[] = []
@@ -162,21 +160,21 @@ const modelLabel = (model: string, effort?: string, fast?: boolean) =>
export function GoodVibesHeart({ tick, t }: { tick: number; t: Theme }) {
const [active, setActive] = useState(false)
const [color, setColor] = useState(t.color.amber)
const [color, setColor] = useState(t.color.accent)
useEffect(() => {
if (tick <= 0) {
return
}
const palette = [...HEART_COLORS, t.color.amber]
const palette = [t.color.error, t.color.warn, t.color.accent]
setColor(palette[Math.floor(Math.random() * palette.length)]!)
setActive(true)
const id = setTimeout(() => setActive(false), 650)
return () => clearTimeout(id)
}, [t.color.amber, tick])
}, [t.color.accent, tick])
if (!active) {
return null
@@ -217,23 +215,23 @@ export function StatusRule({
return (
<Box height={1}>
<Box flexShrink={1} width={leftWidth}>
<Text color={t.color.bronze} wrap="truncate-end">
<Text color={t.color.border} wrap="truncate-end">
{'─ '}
{busy ? (
<FaceTicker color={statusColor} startedAt={turnStartedAt} />
) : (
<Text color={statusColor}>{status}</Text>
)}
<Text color={t.color.dim}> {modelLabel(model, modelReasoningEffort, modelFast)}</Text>
{ctxLabel ? <Text color={t.color.dim}> {ctxLabel}</Text> : null}
<Text color={t.color.muted}> {modelLabel(model, modelReasoningEffort, modelFast)}</Text>
{ctxLabel ? <Text color={t.color.muted}> {ctxLabel}</Text> : null}
{bar ? (
<Text color={t.color.dim}>
<Text color={t.color.muted}>
{' │ '}
<Text color={barColor}>[{bar}]</Text> <Text color={barColor}>{pct != null ? `${pct}%` : ''}</Text>
</Text>
) : null}
{sessionStartedAt ? (
<Text color={t.color.dim}>
<Text color={t.color.muted}>
{' │ '}
<SessionDuration startedAt={sessionStartedAt} />
</Text>
@@ -242,42 +240,26 @@ export function StatusRule({
{voiceLabel ? (
<Text
color={
voiceLabel.startsWith('●') ? t.color.error : voiceLabel.startsWith('◉') ? t.color.warn : t.color.dim
voiceLabel.startsWith('●') ? t.color.error : voiceLabel.startsWith('◉') ? t.color.warn : t.color.muted
}
>
{' │ '}
{voiceLabel}
</Text>
) : null}
{bgCount > 0 ? <Text color={t.color.dim}> {bgCount} bg</Text> : null}
{bgCount > 0 ? <Text color={t.color.muted}> {bgCount} bg</Text> : null}
{showCost && typeof usage.cost_usd === 'number' ? (
<Text color={t.color.dim}> ${usage.cost_usd.toFixed(4)}</Text>
<Text color={t.color.muted}> ${usage.cost_usd.toFixed(4)}</Text>
) : null}
</Text>
</Box>
<Text color={t.color.bronze}> </Text>
<Text color={t.color.border}> </Text>
<Text color={t.color.label}>{cwdLabel}</Text>
</Box>
)
}
export function FloatBox({ children, color }: { children: ReactNode; color: string }) {
return (
<Box
alignSelf="flex-start"
borderColor={color}
borderStyle="double"
flexDirection="column"
marginTop={1}
opaque
paddingX={1}
>
{children}
</Box>
)
}
export function StickyPromptTracker({ messages, offsets, scrollRef, onChange }: StickyPromptTrackerProps) {
const { atBottom, bottom, top } = useViewportSnapshot(scrollRef)
const text = stickyPromptFromViewport(messages, offsets, top, bottom, atBottom)
@@ -301,8 +283,8 @@ export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps)
const thumb = scrollable ? Math.max(1, Math.round((vp * vp) / total)) : vp
const travel = Math.max(1, vp - thumb)
const thumbTop = scrollable ? Math.round((pos / Math.max(1, total - vp)) * travel) : 0
const thumbColor = grab !== null ? t.color.gold : hover ? t.color.amber : t.color.bronze
const trackColor = hover ? t.color.bronze : t.color.dim
const thumbColor = grab !== null ? t.color.primary : hover ? t.color.accent : t.color.border
const trackColor = hover ? t.color.border : t.color.muted
const jump = (row: number, offset: number) => {
if (!s || !scrollable) {

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 { Fragment, memo, useMemo, useRef } from 'react'
@@ -124,8 +124,13 @@ const ComposerPane = memo(function ComposerPane({
const ui = useStore($uiState)
const isBlocked = useStore($isBlocked)
const sh = (composer.inputBuf[0] ?? composer.input).startsWith('!')
const pw = 2
const inputColumns = stableComposerColumns(composer.cols, pw)
const promptText = sh ? '$' : ui.theme.brand.prompt
const promptLabel = `${promptText} `
const promptWidth = Math.max(1, stringWidth(promptLabel))
// ``pw`` retained as the local alias used by the mouse-drag handlers
// below — semantically the same value, kept short for readability there.
const pw = promptWidth
const inputColumns = stableComposerColumns(composer.cols, promptWidth)
const inputHeight = inputVisualHeight(composer.input, inputColumns)
const inputMouseRef = useRef<null | TextInputMouseApi>(null)
@@ -183,13 +188,13 @@ const ComposerPane = memo(function ComposerPane({
/>
{ui.bgTasks.size > 0 && (
<Text color={ui.theme.color.dim}>
<Text color={ui.theme.color.muted}>
{ui.bgTasks.size} background {ui.bgTasks.size === 1 ? 'task' : 'tasks'} running
</Text>
)}
{status.showStickyPrompt ? (
<Text color={ui.theme.color.dim} wrap="truncate-end">
<Text color={ui.theme.color.muted} wrap="truncate-end">
<Text color={ui.theme.color.label}> </Text>
{status.stickyPrompt}
@@ -214,21 +219,21 @@ const ComposerPane = memo(function ComposerPane({
<>
{composer.inputBuf.map((line, i) => (
<Box key={i}>
<Box width={2}>
<Text color={ui.theme.color.dim}>{i === 0 ? `${ui.theme.brand.prompt} ` : ' '}</Text>
<Box width={promptWidth}>
<Text color={ui.theme.color.muted}>{i === 0 ? promptLabel : ' '.repeat(promptWidth)}</Text>
</Box>
<Text color={ui.theme.color.cornsilk}>{line || ' '}</Text>
<Text color={ui.theme.color.text}>{line || ' '}</Text>
</Box>
))}
<Box onMouseDown={captureInputDrag} onMouseDrag={dragFromPromptRow} onMouseUp={endInputDrag} position="relative">
<Box width={pw}>
<Box width={promptWidth}>
{sh ? (
<Text color={ui.theme.color.shellDollar}>$ </Text>
<Text color={ui.theme.color.shellDollar}>{promptLabel}</Text>
) : (
<Text bold color={ui.theme.color.prompt}>
{composer.inputBuf.length ? ' ' : `${ui.theme.brand.prompt} `}
{composer.inputBuf.length ? ' '.repeat(promptWidth) : promptLabel}
</Text>
)}
</Box>
@@ -254,7 +259,7 @@ const ComposerPane = memo(function ComposerPane({
)}
</Box>
{!composer.empty && !ui.sid && <Text color={ui.theme.color.dim}> {ui.status}</Text>}
{!composer.empty && !ui.sid && <Text color={ui.theme.color.muted}> {ui.status}</Text>}
<StatusRulePane at="bottom" composer={composer} status={status} />
</NoSelect>
@@ -319,6 +324,7 @@ export const AppLayout = memo(function AppLayout({
transcript
}: AppLayoutProps) {
const overlay = useStore($overlayState)
const ui = useStore($uiState)
// Inline mode skips AlternateScreen so the host terminal's native
// scrollback captures rows scrolled off the top; composer + progress
@@ -359,7 +365,7 @@ export const AppLayout = memo(function AppLayout({
{SHOW_FPS && (
<Box flexShrink={0} justifyContent="flex-end" paddingRight={1}>
<FpsOverlay />
<FpsOverlay t={ui.theme} />
</Box>
)}
</>

View File

@@ -1,4 +1,4 @@
import { Box, Text } from '@hermes/ink'
import { Box, Text, useStdout } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import { useGateway } from '../app/gatewayContext.js'
@@ -6,15 +6,18 @@ import type { AppOverlaysProps } from '../app/interfaces.js'
import { $overlayState, patchOverlayState } from '../app/overlayStore.js'
import { $uiState } from '../app/uiStore.js'
import { FloatBox } from './appChrome.js'
import { LearningLedger } from './learningLedger.js'
import { MaskedPrompt } from './maskedPrompt.js'
import { ModelPicker } from './modelPicker.js'
import { OverlayHint } from './overlayControls.js'
import { OverlayGrid } from './overlayGrid.js'
import { ApprovalPrompt, ClarifyPrompt, ConfirmPrompt } from './prompts.js'
import { SessionPicker } from './sessionPicker.js'
import { SkillsHub } from './skillsHub.js'
const COMPLETION_WINDOW = 16
const OVERLAY_GUTTER = 4
const OVERLAY_MIN_WIDTH = 44
export function PromptZone({
cols,
@@ -102,8 +105,15 @@ export function FloatingOverlays({
const { gw } = useGateway()
const overlay = useStore($overlayState)
const ui = useStore($uiState)
const { stdout } = useStdout()
const hasAny = overlay.modelPicker || overlay.pager || overlay.picker || overlay.skillsHub || completions.length
const hasAny =
overlay.learningLedger ||
overlay.modelPicker ||
overlay.pager ||
overlay.picker ||
overlay.skillsHub ||
completions.length
if (!hasAny) {
return null
@@ -115,87 +125,169 @@ export function FloatingOverlays({
const viewportSize = Math.min(COMPLETION_WINDOW, completions.length)
const start = Math.max(0, Math.min(compIdx - Math.floor(COMPLETION_WINDOW / 2), completions.length - viewportSize))
const overlayWidth = Math.max(OVERLAY_MIN_WIDTH, cols - OVERLAY_GUTTER)
const overlayMaxHeight = Math.max(6, Math.min(18, (stdout?.rows ?? 24) - 8))
return (
<Box alignItems="flex-start" bottom="100%" flexDirection="column" left={0} position="absolute" right={0}>
{overlay.picker && (
<FloatBox color={ui.theme.color.bronze}>
<SessionPicker
gw={gw}
onCancel={() => patchOverlayState({ picker: false })}
onSelect={onPickerSelect}
t={ui.theme}
/>
</FloatBox>
<OverlayGrid
borderColor={ui.theme.color.border}
panels={[
{
content: (
<SessionPicker
gw={gw}
onCancel={() => patchOverlayState({ picker: false })}
onSelect={onPickerSelect}
t={ui.theme}
/>
),
id: 'sessions'
}
]}
maxHeight={overlayMaxHeight}
t={ui.theme}
width={overlayWidth}
/>
)}
{overlay.modelPicker && (
<FloatBox color={ui.theme.color.bronze}>
<ModelPicker
gw={gw}
onCancel={() => patchOverlayState({ modelPicker: false })}
onSelect={onModelSelect}
sessionId={ui.sid}
t={ui.theme}
/>
</FloatBox>
<OverlayGrid
borderColor={ui.theme.color.border}
panels={[
{
content: (
<ModelPicker
gw={gw}
onCancel={() => patchOverlayState({ modelPicker: false })}
onSelect={onModelSelect}
sessionId={ui.sid}
t={ui.theme}
/>
),
id: 'models'
}
]}
maxHeight={overlayMaxHeight}
t={ui.theme}
width={overlayWidth}
/>
)}
{overlay.skillsHub && (
<FloatBox color={ui.theme.color.bronze}>
<SkillsHub gw={gw} onClose={() => patchOverlayState({ skillsHub: false })} t={ui.theme} />
</FloatBox>
<OverlayGrid
borderColor={ui.theme.color.border}
panels={[
{
content: <SkillsHub gw={gw} onClose={() => patchOverlayState({ skillsHub: false })} t={ui.theme} />,
id: 'skills'
}
]}
maxHeight={overlayMaxHeight}
t={ui.theme}
width={overlayWidth}
/>
)}
{overlay.learningLedger && (
<LearningLedger
borderColor={ui.theme.color.border}
gw={gw}
onClose={() => patchOverlayState({ learningLedger: false })}
t={ui.theme}
width={overlayWidth}
maxHeight={overlayMaxHeight}
/>
)}
{overlay.pager && (
<FloatBox color={ui.theme.color.bronze}>
<Box flexDirection="column" paddingX={1} paddingY={1}>
{overlay.pager.title && (
<Box justifyContent="center" marginBottom={1}>
<Text bold color={ui.theme.color.gold}>
{overlay.pager.title}
</Text>
</Box>
)}
<OverlayGrid
borderColor={ui.theme.color.border}
panels={[
{
content: (
<Box flexDirection="column">
{overlay.pager.lines
.slice(overlay.pager.offset, overlay.pager.offset + pagerPageSize)
.map((line, i) => (
<Text key={i}>{line}</Text>
))}
{overlay.pager.lines.slice(overlay.pager.offset, overlay.pager.offset + pagerPageSize).map((line, i) => (
<Text key={i}>{line}</Text>
))}
<Box marginTop={1}>
<OverlayHint t={ui.theme}>
{overlay.pager.offset + pagerPageSize < overlay.pager.lines.length
? `↑↓/jk line · Enter/Space/PgDn page · b/PgUp back · g/G top/bottom · Esc/q close (${Math.min(overlay.pager.offset + pagerPageSize, overlay.pager.lines.length)}/${overlay.pager.lines.length})`
: `end · ↑↓/jk · b/PgUp back · g top · Esc/q close (${overlay.pager.lines.length} lines)`}
</OverlayHint>
</Box>
</Box>
</FloatBox>
</Box>
),
footer: (
<OverlayHint t={ui.theme}>
{overlay.pager.offset + pagerPageSize < overlay.pager.lines.length
? `↑↓/jk line · Enter/Space/PgDn page · b/PgUp back · g/G top/bottom · Esc/q close (${Math.min(overlay.pager.offset + pagerPageSize, overlay.pager.lines.length)}/${overlay.pager.lines.length})`
: `end · ↑↓/jk · b/PgUp back · g top · Esc/q close (${overlay.pager.lines.length} lines)`}
</OverlayHint>
),
id: 'pager',
title: overlay.pager.title
}
]}
maxHeight={overlayMaxHeight}
t={ui.theme}
width={overlayWidth}
/>
)}
{!!completions.length && (
<FloatBox color={ui.theme.color.gold}>
<Box flexDirection="column" width={Math.max(28, cols - 6)}>
{completions.slice(start, start + viewportSize).map((item, i) => {
const active = start + i === compIdx
<OverlayGrid
borderColor={ui.theme.color.primary}
panels={[
{
content: (
<Box flexDirection="column">
{completions.slice(start, start + viewportSize).map((item, i) => {
const active = start + i === compIdx
return (
<Box
backgroundColor={active ? ui.theme.color.completionCurrentBg : undefined}
flexDirection="row"
key={`${start + i}:${item.text}:${item.display}:${item.meta ?? ''}`}
width="100%"
>
<Text bold color={ui.theme.color.label}>
{' '}
{item.display}
</Text>
{item.meta ? <Text color={ui.theme.color.dim}> {item.meta}</Text> : null}
return (
<Box
backgroundColor={active ? ui.theme.color.completionCurrentBg : undefined}
key={`${start + i}:${item.text}`}
width="100%"
>
<Text bold color={ui.theme.color.label} wrap="truncate-end">
{item.display}
</Text>
</Box>
)
})}
</Box>
)
})}
</Box>
</FloatBox>
),
grow: 4,
id: 'completion-list'
},
{
content: (
<Box flexDirection="column">
{completions.slice(start, start + viewportSize).map((item, i) => {
const active = start + i === compIdx
return (
<Box
backgroundColor={active ? ui.theme.color.completionCurrentBg : undefined}
key={`${start + i}:${item.text}:meta`}
width="100%"
>
<Text color={ui.theme.color.muted} wrap="truncate-end">
{item.meta ?? ' '}
</Text>
</Box>
)
})}
</Box>
),
grow: 6,
id: 'completion-meta'
}
]}
maxHeight={overlayMaxHeight}
t={ui.theme}
width={overlayWidth}
/>
)}
</Box>
)

View File

@@ -26,12 +26,12 @@ export function Banner({ t }: { t: Theme }) {
{cols >= (t.bannerLogo ? artWidth(logoLines) : LOGO_WIDTH) ? (
<ArtLines lines={logoLines} />
) : (
<Text bold color={t.color.gold}>
<Text bold color={t.color.primary}>
{t.brand.icon} NOUS HERMES
</Text>
)}
<Text color={t.color.dim}>{t.brand.icon} Nous Research · Messenger of the Digital Gods</Text>
<Text color={t.color.muted}>{t.brand.icon} Nous Research · Messenger of the Digital Gods</Text>
</Box>
)
}
@@ -70,39 +70,49 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) {
return (
<Box flexDirection="column" marginTop={1}>
<Text bold color={t.color.amber}>
<Text bold color={t.color.accent}>
Available {title}
</Text>
{shown.map(([k, vs]) => (
<Text key={k} wrap="truncate">
<Text color={t.color.dim}>{strip(k)}: </Text>
<Text color={t.color.cornsilk}>{truncLine(strip(k) + ': ', vs)}</Text>
<Text color={t.color.muted}>{strip(k)}: </Text>
<Text color={t.color.text}>{truncLine(strip(k) + ': ', vs)}</Text>
</Text>
))}
{overflow > 0 && (
<Text color={t.color.dim}>
<Text color={t.color.muted}>
(and {overflow} {overflowLabel})
</Text>
)}
</Box>
)
}
const learningLine = (() => {
const counts = info.learning?.counts ?? {}
const parts = [
counts.user || counts.memory ? `${(counts.user ?? 0) + (counts.memory ?? 0)} memories` : '',
counts.recall ? `${counts.recall} recalls` : '',
counts['skill-use'] ? `${counts['skill-use']} applied skills` : ''
].filter(Boolean)
return parts.length ? `learned: ${parts.join(' · ')}` : ''
})()
return (
<Box borderColor={t.color.bronze} borderStyle="round" marginBottom={1} paddingX={2} paddingY={1}>
<Box borderColor={t.color.border} borderStyle="round" marginBottom={1} paddingX={2} paddingY={1}>
{wide && (
<Box flexDirection="column" marginRight={2} width={leftW}>
<ArtLines lines={heroLines} />
<Text />
<Text color={t.color.amber}>
<Text color={t.color.accent}>
{info.model.split('/').pop()}
<Text color={t.color.dim}> · Nous Research</Text>
<Text color={t.color.muted}> · Nous Research</Text>
</Text>
<Text color={t.color.dim} wrap="truncate-end">
<Text color={t.color.muted} wrap="truncate-end">
{info.cwd || process.cwd()}
</Text>
@@ -117,7 +127,7 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) {
<Box flexDirection="column" width={w}>
<Box justifyContent="center" marginBottom={1}>
<Text bold color={t.color.gold}>
<Text bold color={t.color.primary}>
{t.brand.name}
{info.version ? ` v${info.version}` : ''}
{info.release_date ? ` (${info.release_date})` : ''}
@@ -129,17 +139,17 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) {
{info.mcp_servers && info.mcp_servers.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text bold color={t.color.amber}>
<Text bold color={t.color.accent}>
MCP Servers
</Text>
{info.mcp_servers.map(s => (
<Text key={s.name} wrap="truncate">
<Text color={t.color.dim}>{` ${s.name} `}</Text>
<Text color={t.color.dim}>{`[${s.transport}]`}</Text>
<Text color={t.color.dim}>: </Text>
<Text color={t.color.muted}>{` ${s.name} `}</Text>
<Text color={t.color.muted}>{`[${s.transport}]`}</Text>
<Text color={t.color.muted}>: </Text>
{s.connected ? (
<Text color={t.color.cornsilk}>
<Text color={t.color.text}>
{s.tools} tool{s.tools === 1 ? '' : 's'}
</Text>
) : (
@@ -152,14 +162,20 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) {
<Text />
<Text color={t.color.cornsilk}>
<Text color={t.color.text}>
{flat(info.tools).length} tools{' · '}
{flat(info.skills).length} skills
{info.mcp_servers?.length ? ` · ${info.mcp_servers.length} MCP` : ''}
{' · '}
<Text color={t.color.dim}>/help for commands</Text>
<Text color={t.color.muted}>/help for commands</Text>
</Text>
{learningLine && (
<Text color={t.color.text} dimColor italic>
{learningLine} · /learned
</Text>
)}
{typeof info.update_behind === 'number' && info.update_behind > 0 && (
<Text bold color={t.color.warn}>
! {info.update_behind} {info.update_behind === 1 ? 'commit' : 'commits'} behind
@@ -183,9 +199,9 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) {
export function Panel({ sections, t, title }: PanelProps) {
return (
<Box borderColor={t.color.bronze} borderStyle="round" flexDirection="column" paddingX={2} paddingY={1}>
<Box borderColor={t.color.border} borderStyle="round" flexDirection="column" paddingX={2} paddingY={1}>
<Box justifyContent="center" marginBottom={1}>
<Text bold color={t.color.gold}>
<Text bold color={t.color.primary}>
{title}
</Text>
</Box>
@@ -193,25 +209,25 @@ export function Panel({ sections, t, title }: PanelProps) {
{sections.map((sec, si) => (
<Box flexDirection="column" key={si} marginTop={si > 0 ? 1 : 0}>
{sec.title && (
<Text bold color={t.color.amber}>
<Text bold color={t.color.accent}>
{sec.title}
</Text>
)}
{sec.rows?.map(([k, v], ri) => (
<Text key={ri} wrap="truncate">
<Text color={t.color.dim}>{k.padEnd(20)}</Text>
<Text color={t.color.cornsilk}>{v}</Text>
<Text color={t.color.muted}>{k.padEnd(20)}</Text>
<Text color={t.color.text}>{v}</Text>
</Text>
))}
{sec.items?.map((item, ii) => (
<Text color={t.color.cornsilk} key={ii} wrap="truncate">
<Text color={t.color.text} key={ii} wrap="truncate">
{item}
</Text>
))}
{sec.text && <Text color={t.color.dim}>{sec.text}</Text>}
{sec.text && <Text color={t.color.muted}>{sec.text}</Text>}
</Box>
))}
</Box>

View File

@@ -5,23 +5,25 @@ import { useStore } from '@nanostores/react'
import { SHOW_FPS } from '../config/env.js'
import { $fpsState } from '../lib/fpsStore.js'
import type { Theme } from '../theme.js'
const fpsColor = (fps: number) => (fps >= 50 ? 'green' : fps >= 30 ? 'yellow' : 'red')
const fpsColor = (fps: number, t: Theme) =>
fps >= 50 ? t.color.statusGood : fps >= 30 ? t.color.statusWarn : t.color.error
export function FpsOverlay() {
export function FpsOverlay({ t }: { t: Theme }) {
if (!SHOW_FPS) {
return null
}
return <FpsOverlayInner />
return <FpsOverlayInner t={t} />
}
function FpsOverlayInner() {
function FpsOverlayInner({ t }: { t: Theme }) {
const { fps, lastDurationMs, totalFrames } = useStore($fpsState)
// Zero-pad widths so digit churn doesn't jitter the corner.
return (
<Text color={fpsColor(fps)}>
<Text color={fpsColor(fps, t)}>
{fps.toFixed(1).padStart(5)}fps · {lastDurationMs.toFixed(1).padStart(5)}ms · #{totalFrames}
</Text>
)

View File

@@ -0,0 +1,316 @@
import { Box, Text, useInput, useStdout } from '@hermes/ink'
import { useEffect, useState } from 'react'
import type { GatewayClient } from '../gatewayClient.js'
import { rpcErrorMessage } from '../lib/rpc.js'
import type { Theme } from '../theme.js'
import { OverlayGrid } from './overlayGrid.js'
import { OverlayHint, windowItems, windowOffset } from './overlayControls.js'
const EDGE_GUTTER = 10
const MAX_WIDTH = 132
const MIN_WIDTH = 64
const VISIBLE_ROWS = 12
const LISTS = [
{ id: 'memories', title: 'Memories', types: ['user', 'memory'] },
{ id: 'skills', title: 'Skills', types: ['skill-use'] },
{ id: 'recalls', title: 'Recalls', types: ['recall'] },
{ id: 'connected', title: 'Connected', types: ['integration'] }
] as const
const typeIcon: Record<string, string> = {
integration: '◇',
memory: '◆',
recall: '↺',
'skill-use': '✦',
user: '●'
}
const fmtTime = (ts?: null | number) => {
if (!ts) {
return ''
}
const days = Math.floor((Date.now() - ts * 1000) / 86_400_000)
return days <= 0 ? 'today' : `${days}d ago`
}
export function LearningLedger({ borderColor, gw, maxHeight, onClose, t, width: fixedWidth }: LearningLedgerProps) {
const [ledger, setLedger] = useState<LearningLedgerResponse | null>(null)
const [activeList, setActiveList] = useState(0)
const [indices, setIndices] = useState<Record<string, number>>({})
const [expanded, setExpanded] = useState(false)
const [err, setErr] = useState('')
const [loading, setLoading] = useState(true)
const { stdout } = useStdout()
const width = fixedWidth ?? Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - EDGE_GUTTER))
useEffect(() => {
gw.request<LearningLedgerResponse>('learning.ledger', { limit: 120 })
.then(r => {
setLedger(r)
setErr('')
})
.catch((e: unknown) => setErr(rpcErrorMessage(e)))
.finally(() => setLoading(false))
}, [gw])
const items = ledger?.items ?? []
const lists = LISTS.map(list => ({
...list,
items: items.filter(item => list.types.includes(item.type as never))
}))
const active = lists[activeList] ?? lists[0]!
const activeIdx = Math.min(indices[active.id] ?? 0, Math.max(0, active.items.length - 1))
const selected = active.items[activeIdx]
const detailOpen = expanded && !!selected
useInput((ch, key) => {
if (key.escape || ch.toLowerCase() === 'q') {
onClose()
return
}
if (key.leftArrow && activeList > 0) {
setActiveList(v => v - 1)
return
}
if (key.rightArrow && activeList < lists.length - 1) {
setActiveList(v => v + 1)
return
}
if (key.upArrow && activeIdx > 0) {
setIndices(v => ({ ...v, [active.id]: activeIdx - 1 }))
return
}
if (key.downArrow && activeIdx < active.items.length - 1) {
setIndices(v => ({ ...v, [active.id]: activeIdx + 1 }))
return
}
if (key.return || ch === ' ') {
setExpanded(v => !v)
return
}
const n = ch === '0' ? 10 : parseInt(ch, 10)
if (!Number.isNaN(n) && n >= 1 && n <= Math.min(10, active.items.length)) {
const next = windowOffset(active.items.length, activeIdx, VISIBLE_ROWS) + n - 1
if (active.items[next]) {
setIndices(v => ({ ...v, [active.id]: next }))
}
}
})
if (loading) {
return <Text color={t.color.muted}>indexing learning ledger</Text>
}
if (err) {
return (
<Box flexDirection="column" width={width}>
<Text color={t.color.label}>learning ledger error: {err}</Text>
<OverlayHint t={t}>Esc/q close</OverlayHint>
</Box>
)
}
if (!items.length) {
return (
<Box flexDirection="column" width={width}>
<Text bold color={t.color.accent}>
Recent Learning
</Text>
<Text color={t.color.muted}>no memories, recalls, used skills, or integrations found yet</Text>
{ledger?.inventory?.skills ? (
<Text color={t.color.muted}>available knowledge: {ledger.inventory.skills} installed skills</Text>
) : null}
<OverlayHint t={t}>Esc/q close</OverlayHint>
</Box>
)
}
const listPanels = lists.map((list, listIdx) => {
const selectedIndex = Math.min(indices[list.id] ?? 0, Math.max(0, list.items.length - 1))
const { items: visible, offset } = windowItems(list.items, selectedIndex, Math.max(3, Math.floor(VISIBLE_ROWS / 2)))
return {
content: (
<LearningList
active={activeList === listIdx}
items={visible}
offset={offset}
selectedIndex={selectedIndex}
t={t}
total={list.items.length}
/>
),
grow: 1,
id: `learning-${list.id}`,
title: list.title
}
})
return (
<OverlayGrid
borderColor={borderColor}
footer={<OverlayHint t={t}>/ panel · / select · Enter/Space details · 1-9,0 quick · Esc/q close</OverlayHint>}
panels={[
...listPanels,
...(detailOpen && selected
? [
{
content: <LedgerDetails item={selected} t={t} />,
grow: 2,
id: 'learning-details',
title: 'Details'
}
]
: [])
]}
maxHeight={maxHeight}
t={t}
width={width}
/>
)
}
function LearningList({ active, items, offset, selectedIndex, t, total }: LearningListProps) {
return (
<Box flexDirection="column">
<Text color={active ? t.color.accent : t.color.muted}>{total} item{total === 1 ? '' : 's'}</Text>
{offset > 0 && <Text color={t.color.muted}> {offset} more</Text>}
<Box flexDirection="column">
{items.map((item, i) => {
const absolute = offset + i
return (
<LedgerRow
active={active && absolute === selectedIndex}
index={i + 1}
item={item}
key={`${item.type}:${item.name}:${i}`}
t={t}
/>
)
})}
</Box>
{offset + items.length < total && (
<Text color={t.color.muted}> {total - offset - items.length} more</Text>
)}
</Box>
)
}
function LedgerRow({ active, index, item, t }: LedgerRowProps) {
const when = fmtTime(item.last_used_at ?? item.learned_at)
const count = item.count ? ` ×${item.count}` : ''
const icon = typeIcon[item.type] ?? '•'
const title = compactTitle(item)
return (
<Box flexShrink={0} width="100%">
<Text bold={active} color={active ? t.color.accent : t.color.muted} inverse={active} wrap="truncate-end">
{active ? '▸ ' : ' '}
{index}. {icon} {title}
<Text color={active ? t.color.accent : t.color.muted}>
{' '}
{count}
{when ? ` · ${when}` : ''}
</Text>
</Text>
</Box>
)
}
function compactTitle(item: LearningLedgerItem) {
const raw = item.type === 'memory' || item.type === 'user' ? item.summary : item.name
return raw
.replace(/^User\s+/i, '')
.replace(/^Durable memory updates$/i, 'memory updated')
.replace(/^session_search$/i, 'past sessions')
}
function LedgerDetails({ item, t }: LedgerDetailsProps) {
const memoryLike = item.type === 'memory' || item.type === 'user'
return (
<Box flexDirection="column">
<Text color={t.color.primary} wrap="truncate-end">
{memoryLike ? item.name : item.summary}
</Text>
{memoryLike ? <Text color={t.color.text}>{item.summary}</Text> : null}
{item.count ? <Text color={t.color.muted}>used: {item.count}×</Text> : null}
{item.learned_from ? <Text color={t.color.muted}>from: {item.learned_from}</Text> : null}
{item.via ? <Text color={t.color.muted}>via: {item.via}</Text> : null}
{item.last_used_at ? <Text color={t.color.muted}>last used: {fmtTime(item.last_used_at)}</Text> : null}
<Text color={t.color.muted}>source: {item.source}</Text>
</Box>
)
}
interface LearningLedgerItem {
count?: number
learned_from?: null | string
last_used_at?: null | number
learned_at?: null | number
name: string
source: string
summary: string
type: string
via?: null | string
}
interface LearningLedgerResponse {
counts?: Record<string, number>
generated_at?: number
home?: string
inventory?: { skills?: number }
items?: LearningLedgerItem[]
total?: number
}
interface LearningListProps {
active: boolean
items: LearningLedgerItem[]
offset: number
selectedIndex: number
t: Theme
total: number
}
interface LedgerRowProps {
active: boolean
index: number
item: LearningLedgerItem
t: Theme
}
interface LedgerDetailsProps {
item: LearningLedgerItem
t: Theme
}
interface LearningLedgerProps {
borderColor: string
gw: GatewayClient
maxHeight?: number
onClose: () => void
t: Theme
width?: number
}

View File

@@ -72,7 +72,7 @@ const autolinkUrl = (raw: string) =>
const renderAutolink = (k: number, t: Theme, raw: string) => (
<Link key={k} url={autolinkUrl(raw)}>
<Text color={t.color.amber} underline>
<Text color={t.color.accent} underline>
{raw.replace(/^mailto:/, '')}
</Text>
</Link>
@@ -113,7 +113,7 @@ const renderTable = (k: number, rows: string[][], t: Theme) => {
<Fragment key={ri}>
<Box>
{widths.map((w, ci) => (
<Text bold={ri === 0} color={ri === 0 ? t.color.amber : undefined} key={ci}>
<Text bold={ri === 0} color={ri === 0 ? t.color.accent : undefined} key={ci}>
<MdInline t={t} text={row[ci] ?? ''} />
{' '.repeat(Math.max(0, w - stripInlineMarkup(row[ci] ?? '').length))}
{ci < widths.length - 1 ? ' ' : ''}
@@ -121,7 +121,7 @@ const renderTable = (k: number, rows: string[][], t: Theme) => {
))}
</Box>
{ri === 0 && rows.length > 1 ? (
<Text color={t.color.dim} dimColor>
<Text color={t.color.muted} dimColor>
{sep}
</Text>
) : null}
@@ -146,14 +146,14 @@ function MdInline({ t, text }: { t: Theme; text: string }) {
if (m[1] && m[2]) {
parts.push(
<Text color={t.color.dim} key={parts.length}>
<Text color={t.color.muted} key={parts.length}>
[image: {m[1]}] {m[2]}
</Text>
)
} else if (m[3] && m[4]) {
parts.push(
<Link key={parts.length} url={m[4]}>
<Text color={t.color.amber} underline>
<Text color={t.color.accent} underline>
{m[3]}
</Text>
</Link>
@@ -168,7 +168,7 @@ function MdInline({ t, text }: { t: Theme; text: string }) {
)
} else if (m[7]) {
parts.push(
<Text color={t.color.amber} dimColor key={parts.length}>
<Text color={t.color.accent} dimColor key={parts.length}>
{m[7]}
</Text>
)
@@ -192,19 +192,19 @@ function MdInline({ t, text }: { t: Theme; text: string }) {
)
} else if (m[13]) {
parts.push(
<Text color={t.color.dim} key={parts.length}>
<Text color={t.color.muted} key={parts.length}>
[{m[13]}]
</Text>
)
} else if (m[14]) {
parts.push(
<Text color={t.color.dim} key={parts.length}>
<Text color={t.color.muted} key={parts.length}>
^{m[14]}
</Text>
)
} else if (m[15]) {
parts.push(
<Text color={t.color.dim} key={parts.length}>
<Text color={t.color.muted} key={parts.length}>
_{m[15]}
</Text>
)
@@ -324,11 +324,11 @@ function MdImpl({ compact, t, text }: MdProps) {
if (media) {
start('paragraph')
nodes.push(
<Text color={t.color.dim} key={key}>
<Text color={t.color.muted} key={key}>
{'▸ '}
<Link url={/^(?:\/|[a-z]:[\\/])/i.test(media) ? `file://${media}` : media}>
<Text color={t.color.amber} underline>
<Text color={t.color.accent} underline>
{media}
</Text>
</Link>
@@ -375,7 +375,7 @@ function MdImpl({ compact, t, text }: MdProps) {
nodes.push(
<Box flexDirection="column" key={key} paddingLeft={2}>
{lang && !isDiff && <Text color={t.color.dim}>{'─ ' + lang}</Text>}
{lang && !isDiff && <Text color={t.color.muted}>{'─ ' + lang}</Text>}
{block.map((l, j) => {
if (highlighted) {
@@ -401,7 +401,7 @@ function MdImpl({ compact, t, text }: MdProps) {
return (
<Text
backgroundColor={add ? t.color.diffAdded : del ? t.color.diffRemoved : undefined}
color={add ? t.color.diffAddedWord : del ? t.color.diffRemovedWord : hunk ? t.color.dim : undefined}
color={add ? t.color.diffAddedWord : del ? t.color.diffRemovedWord : hunk ? t.color.muted : undefined}
dimColor={isDiff && !add && !del && !hunk && l.startsWith(' ')}
key={j}
>
@@ -432,10 +432,10 @@ function MdImpl({ compact, t, text }: MdProps) {
nodes.push(
<Box flexDirection="column" key={key} paddingLeft={2}>
<Text color={t.color.dim}> math</Text>
<Text color={t.color.muted}> math</Text>
{block.map((l, j) => (
<Text color={t.color.amber} key={j}>
<Text color={t.color.accent} key={j}>
{l}
</Text>
))}
@@ -450,7 +450,7 @@ function MdImpl({ compact, t, text }: MdProps) {
if (heading) {
start('heading')
nodes.push(
<Text bold color={t.color.amber} key={key}>
<Text bold color={t.color.accent} key={key}>
{heading}
</Text>
)
@@ -462,7 +462,7 @@ function MdImpl({ compact, t, text }: MdProps) {
if (i + 1 < lines.length && SETEXT_RE.test(lines[i + 1]!)) {
start('heading')
nodes.push(
<Text bold color={t.color.amber} key={key}>
<Text bold color={t.color.accent} key={key}>
{line.trim()}
</Text>
)
@@ -474,7 +474,7 @@ function MdImpl({ compact, t, text }: MdProps) {
if (HR_RE.test(line)) {
start('rule')
nodes.push(
<Text color={t.color.dim} key={key}>
<Text color={t.color.muted} key={key}>
{'─'.repeat(36)}
</Text>
)
@@ -488,7 +488,7 @@ function MdImpl({ compact, t, text }: MdProps) {
if (footnote) {
start('list')
nodes.push(
<Text color={t.color.dim} key={key}>
<Text color={t.color.muted} key={key}>
[{footnote[1]}] <MdInline t={t} text={footnote[2] ?? ''} />
</Text>
)
@@ -497,7 +497,7 @@ function MdImpl({ compact, t, text }: MdProps) {
while (i < lines.length && /^\s{2,}\S/.test(lines[i]!)) {
nodes.push(
<Box key={`${key}-cont-${i}`} paddingLeft={2}>
<Text color={t.color.dim}>
<Text color={t.color.muted}>
<MdInline t={t} text={lines[i]!.trim()} />
</Text>
</Box>
@@ -526,7 +526,7 @@ function MdImpl({ compact, t, text }: MdProps) {
nodes.push(
<Text key={`${key}-def-${i}`}>
<Text color={t.color.dim}> · </Text>
<Text color={t.color.muted}> · </Text>
<MdInline t={t} text={def} />
</Text>
)
@@ -546,7 +546,7 @@ function MdImpl({ compact, t, text }: MdProps) {
nodes.push(
<Text key={key}>
<Text color={t.color.dim}>
<Text color={t.color.muted}>
{' '.repeat(indentDepth(bullet[1]!) * 2)}
{marker}{' '}
</Text>
@@ -565,7 +565,7 @@ function MdImpl({ compact, t, text }: MdProps) {
start('list')
nodes.push(
<Text key={key}>
<Text color={t.color.dim}>
<Text color={t.color.muted}>
{' '.repeat(indentDepth(numbered[1]!) * 2)}
{numbered[2]}.{' '}
</Text>
@@ -593,7 +593,7 @@ function MdImpl({ compact, t, text }: MdProps) {
nodes.push(
<Box flexDirection="column" key={key}>
{quoteLines.map((ql, qi) => (
<Text color={t.color.dim} key={qi}>
<Text color={t.color.muted} key={qi}>
{' '.repeat(Math.max(0, ql.depth - 1) * 2)}
{'│ '}
<MdInline t={t} text={ql.text} />
@@ -630,7 +630,7 @@ function MdImpl({ compact, t, text }: MdProps) {
if (summary) {
start('paragraph')
nodes.push(
<Text color={t.color.dim} key={key}>
<Text color={t.color.muted} key={key}>
{summary}
</Text>
)
@@ -642,7 +642,7 @@ function MdImpl({ compact, t, text }: MdProps) {
if (/^<\/?[^>]+>$/.test(line.trim())) {
start('paragraph')
nodes.push(
<Text color={t.color.dim} key={key}>
<Text color={t.color.muted} key={key}>
{line.trim()}
</Text>
)

View File

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

View File

@@ -80,13 +80,13 @@ export const MessageLine = memo(function MessageLine({
const preview = compactPreview(stripped, maxChars) || '(empty tool result)'
return (
<Box alignSelf="flex-start" borderColor={t.color.dim} borderStyle="round" marginLeft={3} paddingX={1}>
<Box alignSelf="flex-start" borderColor={t.color.muted} borderStyle="round" marginLeft={3} paddingX={1}>
{hasAnsi(msg.text) ? (
<Text wrap="truncate-end">
<Ansi>{msg.text}</Ansi>
</Text>
) : (
<Text color={t.color.dim} wrap="truncate-end">
<Text color={t.color.muted} wrap="truncate-end">
{preview}
</Text>
)}
@@ -94,6 +94,16 @@ export const MessageLine = memo(function MessageLine({
)
}
if (msg.kind === 'learning') {
return (
<Box marginLeft={3} marginTop={1}>
<Text color={t.color.muted} italic>
{msg.text}
</Text>
</Box>
)
}
const { body, glyph, prefix } = ROLE[msg.role](t)
const showDetails =
@@ -101,7 +111,7 @@ export const MessageLine = memo(function MessageLine({
const content = (() => {
if (msg.kind === 'slash') {
return <Text color={t.color.dim}>{msg.text}</Text>
return <Text color={t.color.muted}>{msg.text}</Text>
}
if (msg.role !== 'user' && hasAnsi(msg.text)) {
@@ -125,7 +135,7 @@ export const MessageLine = memo(function MessageLine({
return (
<Text color={body}>
{head}
<Text color={t.color.dim} dimColor>
<Text color={t.color.muted} dimColor>
[long message]
</Text>
{rest.join('')}

View File

@@ -25,10 +25,8 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
const [stage, setStage] = useState<'model' | 'provider'>('provider')
const { stdout } = useStdout()
// Pin the picker to a stable width so the FloatBox parent (which shrinks-
// to-fit with alignSelf="flex-start") doesn't resize as long provider /
// model names scroll into view, and so `wrap="truncate-end"` on each row
// has an actual constraint to truncate against.
// Pin the picker to a stable width so long provider / model names scroll
// into view without changing the overlay grid's measured layout.
const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6))
useEffect(() => {
@@ -146,7 +144,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
})
if (loading) {
return <Text color={t.color.dim}>loading models</Text>
return <Text color={t.color.muted}>loading models</Text>
}
if (err) {
@@ -161,7 +159,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
if (!providers.length) {
return (
<Box flexDirection="column">
<Text color={t.color.dim}>no authenticated providers</Text>
<Text color={t.color.muted}>no authenticated providers</Text>
<OverlayHint t={t}>Esc/q cancel</OverlayHint>
</Box>
)
@@ -176,21 +174,21 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
return (
<Box flexDirection="column" width={width}>
<Text bold color={t.color.amber} wrap="truncate-end">
<Text bold color={t.color.accent} wrap="truncate-end">
Select provider (step 1/2)
</Text>
<Text color={t.color.dim} wrap="truncate-end">
<Text color={t.color.muted} wrap="truncate-end">
Full model IDs on the next step · Enter to continue
</Text>
<Text color={t.color.dim} wrap="truncate-end">
<Text color={t.color.muted} wrap="truncate-end">
Current: {currentModel || '(unknown)'}
</Text>
<Text color={t.color.label} wrap="truncate-end">
{provider?.warning ? `warning: ${provider.warning}` : ' '}
</Text>
<Text color={t.color.dim} wrap="truncate-end">
<Text color={t.color.muted} wrap="truncate-end">
{offset > 0 ? `${offset} more` : ' '}
</Text>
@@ -201,7 +199,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
return row ? (
<Text
bold={providerIdx === idx}
color={providerIdx === idx ? t.color.amber : t.color.dim}
color={providerIdx === idx ? t.color.accent : t.color.muted}
inverse={providerIdx === idx}
key={providers[idx]?.slug ?? `row-${idx}`}
wrap="truncate-end"
@@ -210,17 +208,17 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
{i + 1}. {row}
</Text>
) : (
<Text color={t.color.dim} key={`pad-${i}`} wrap="truncate-end">
<Text color={t.color.muted} key={`pad-${i}`} wrap="truncate-end">
{' '}
</Text>
)
})}
<Text color={t.color.dim} wrap="truncate-end">
<Text color={t.color.muted} wrap="truncate-end">
{offset + VISIBLE < rows.length ? `${rows.length - offset - VISIBLE} more` : ' '}
</Text>
<Text color={t.color.dim} wrap="truncate-end">
<Text color={t.color.muted} wrap="truncate-end">
persist: {persistGlobal ? 'global' : 'session'} · g toggle
</Text>
<OverlayHint t={t}>/ select · Enter choose · 1-9,0 quick · Esc/q cancel</OverlayHint>
@@ -232,17 +230,17 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
return (
<Box flexDirection="column" width={width}>
<Text bold color={t.color.amber} wrap="truncate-end">
<Text bold color={t.color.accent} wrap="truncate-end">
Select model (step 2/2)
</Text>
<Text color={t.color.dim} wrap="truncate-end">
<Text color={t.color.muted} wrap="truncate-end">
{names[providerIdx] || '(unknown provider)'} · Esc back
</Text>
<Text color={t.color.label} wrap="truncate-end">
{provider?.warning ? `warning: ${provider.warning}` : ' '}
</Text>
<Text color={t.color.dim} wrap="truncate-end">
<Text color={t.color.muted} wrap="truncate-end">
{offset > 0 ? `${offset} more` : ' '}
</Text>
@@ -252,11 +250,11 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
if (!row) {
return !models.length && i === 0 ? (
<Text color={t.color.dim} key="empty" wrap="truncate-end">
<Text color={t.color.muted} key="empty" wrap="truncate-end">
no models listed for this provider
</Text>
) : (
<Text color={t.color.dim} key={`pad-${i}`} wrap="truncate-end">
<Text color={t.color.muted} key={`pad-${i}`} wrap="truncate-end">
{' '}
</Text>
)
@@ -267,7 +265,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
return (
<Text
bold={modelIdx === idx}
color={modelIdx === idx ? t.color.amber : t.color.dim}
color={modelIdx === idx ? t.color.accent : t.color.muted}
inverse={modelIdx === idx}
key={`${provider?.slug ?? 'prov'}:${idx}:${row}`}
wrap="truncate-end"
@@ -278,11 +276,11 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
)
})}
<Text color={t.color.dim} wrap="truncate-end">
<Text color={t.color.muted} wrap="truncate-end">
{offset + VISIBLE < models.length ? `${models.length - offset - VISIBLE} more` : ' '}
</Text>
<Text color={t.color.dim} wrap="truncate-end">
<Text color={t.color.muted} wrap="truncate-end">
persist: {persistGlobal ? 'global' : 'session'} · g toggle
</Text>
<OverlayHint t={t}>

View File

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

View File

@@ -0,0 +1,79 @@
import { Box, Text } from '@hermes/ink'
import type { ReactNode } from 'react'
import type { Theme } from '../theme.js'
const GAP = 2
export function OverlayGrid({ borderColor, footer, maxHeight, panels, t, width }: OverlayGridProps) {
const visible = panels.filter(p => p.content)
const innerWidth = Math.max(20, width - 4)
const innerHeight = maxHeight ? Math.max(1, maxHeight - 2) : undefined
const panelHeight = innerHeight ? Math.max(1, innerHeight - (footer ? 1 : 0)) : undefined
const gapTotal = Math.max(0, visible.length - 1) * GAP
const usable = Math.max(1, innerWidth - gapTotal)
const growTotal = visible.reduce((sum, p) => sum + (p.grow ?? 1), 0) || 1
let used = 0
return (
<Box
alignSelf="flex-start"
borderColor={borderColor}
borderStyle="double"
flexDirection="column"
marginTop={1}
opaque
paddingX={1}
width={width}
>
<Box flexDirection="row">
{visible.map((panel, i) => {
const last = i === visible.length - 1
const panelWidth = last
? Math.max(1, usable - used)
: Math.max(1, Math.floor((usable * (panel.grow ?? 1)) / growTotal))
used += panelWidth
return (
<Box flexDirection="row" key={panel.id}>
<Box flexDirection="column" flexShrink={0} width={panelWidth}>
{panel.title ? (
<Text bold color={t.color.accent}>
{panel.title}
</Text>
) : null}
<Box
flexDirection="column"
height={panelHeight ? Math.max(1, panelHeight - (panel.title ? 1 : 0) - (panel.footer ? 1 : 0)) : undefined}
overflow="hidden"
>
{panel.content}
</Box>
{panel.footer ? <Box flexDirection="column">{panel.footer}</Box> : null}
</Box>
{!last ? <Box flexShrink={0} width={GAP} /> : null}
</Box>
)
})}
</Box>
{footer ? <Box flexDirection="column">{footer}</Box> : null}
</Box>
)
}
export interface OverlayGridPanel {
content: ReactNode
footer?: ReactNode
grow?: number
id: string
title?: string
}
interface OverlayGridProps {
borderColor: string
footer?: ReactNode
maxHeight?: number
panels: OverlayGridPanel[]
t: Theme
width: number
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -53,7 +53,6 @@ export type CommandDispatchResponse =
export interface ConfigDisplayConfig {
bell_on_complete?: boolean
busy_input_mode?: string
details_mode?: string
inline_diffs?: boolean
sections?: Record<string, string>
@@ -300,10 +299,6 @@ export interface ReloadMcpResponse {
status?: string
}
export interface ReloadEnvResponse {
updated?: number
}
export interface ProcessStopResponse {
killed?: number
}
@@ -423,6 +418,11 @@ export type GatewayEvent =
| { payload?: GatewaySkin; session_id?: string; type: 'skin.changed' }
| { payload: SessionInfo; session_id?: string; type: 'session.info' }
| { payload?: { text?: string }; session_id?: string; type: 'thinking.delta' }
| {
payload?: { source?: string; summary?: string; title?: string; type?: string; verb?: string; via?: string }
session_id?: string
type: 'learning.event'
}
| { payload?: undefined; session_id?: string; type: 'message.start' }
| { payload?: { kind?: string; text?: string }; session_id?: string; type: 'status.update' }
| { payload?: { state?: 'idle' | 'listening' | 'transcribing' }; session_id?: string; type: 'voice.status' }
@@ -468,7 +468,13 @@ export type GatewayEvent =
| { payload: SubagentEventPayload; session_id?: string; type: 'subagent.complete' }
| { payload: { rendered?: string; text?: string }; session_id?: string; type: 'message.delta' }
| {
payload?: { reasoning?: string; rendered?: string; text?: string; usage?: Usage }
payload?: {
learning_events?: { source?: string; summary?: string; title?: string; type?: string; verb?: string; via?: string }[]
reasoning?: string
rendered?: string
text?: string
usage?: Usage
}
session_id?: string
type: 'message.complete'
}

View File

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

View File

@@ -1,9 +1,9 @@
export interface ThemeColors {
gold: string
amber: string
bronze: string
cornsilk: string
dim: string
primary: string
accent: string
border: string
text: string
muted: string
completionBg: string
completionCurrentBg: string
@@ -88,18 +88,26 @@ const BRAND: ThemeBrand = {
helpHeader: '(^_^)? Commands'
}
const cleanPromptSymbol = (s: string | undefined, fallback: string) => {
const cleaned = String(s ?? '')
.replace(/\s+/g, ' ')
.trim()
return cleaned || fallback
}
export const DARK_THEME: Theme = {
color: {
gold: '#FFD700',
amber: '#FFBF00',
bronze: '#CD7F32',
cornsilk: '#FFF8DC',
primary: '#FFD700',
accent: '#FFBF00',
border: '#CD7F32',
text: '#FFF8DC',
muted: '#CC9B1F',
// Bumped from the old `#B8860B` darkgoldenrod (~53% luminance) which
// read as barely-visible on dark terminals for long body text. The
// new value sits ~60% luminance — readable without losing the "muted /
// secondary" semantic. Field labels still use `label` (65%) which
// stays brighter so hierarchy holds.
dim: '#CC9B1F',
completionBg: '#FFFFFF',
completionCurrentBg: mix('#FFFFFF', '#FFBF00', 0.25),
@@ -141,11 +149,11 @@ export const DARK_THEME: Theme = {
// cleanly (#11300).
export const LIGHT_THEME: Theme = {
color: {
gold: '#8B6914',
amber: '#A0651C',
bronze: '#7A4F1F',
cornsilk: '#3D2F13',
dim: '#7A5A0F',
primary: '#8B6914',
accent: '#A0651C',
border: '#7A4F1F',
text: '#3D2F13',
muted: '#7A5A0F',
completionBg: '#F5F5F5',
completionCurrentBg: mix('#F5F5F5', '#A0651C', 0.25),
@@ -213,19 +221,19 @@ export function fromSkin(
const d = DEFAULT_THEME
const c = (k: string) => colors[k]
const amber = c('ui_accent') ?? c('banner_accent') ?? d.color.amber
const accent = c('banner_accent') ?? c('banner_title') ?? d.color.amber
const dim = c('banner_dim') ?? d.color.dim
const accent = c('ui_accent') ?? c('banner_accent') ?? d.color.accent
const bannerAccent = c('banner_accent') ?? c('banner_title') ?? d.color.accent
const muted = c('banner_dim') ?? d.color.muted
return {
color: {
gold: c('banner_title') ?? d.color.gold,
amber,
bronze: c('banner_border') ?? d.color.bronze,
cornsilk: c('banner_text') ?? d.color.cornsilk,
dim,
completionBg: c('completion_menu_bg') ?? '#FFFFFF',
completionCurrentBg: c('completion_menu_current_bg') ?? mix('#FFFFFF', accent, 0.25),
primary: c('ui_primary') ?? c('banner_title') ?? d.color.primary,
accent,
border: c('ui_border') ?? c('banner_border') ?? d.color.border,
text: c('ui_text') ?? c('banner_text') ?? d.color.text,
muted,
completionBg: c('completion_menu_bg') ?? d.color.completionBg,
completionCurrentBg: c('completion_menu_current_bg') ?? mix(d.color.completionBg, bannerAccent, 0.25),
label: c('ui_label') ?? d.color.label,
ok: c('ui_ok') ?? d.color.ok,
@@ -233,8 +241,8 @@ export function fromSkin(
warn: c('ui_warn') ?? d.color.warn,
prompt: c('prompt') ?? c('banner_text') ?? d.color.prompt,
sessionLabel: c('session_label') ?? dim,
sessionBorder: c('session_border') ?? dim,
sessionLabel: c('session_label') ?? muted,
sessionBorder: c('session_border') ?? muted,
statusBg: d.color.statusBg,
statusFg: d.color.statusFg,
@@ -254,7 +262,7 @@ export function fromSkin(
brand: {
name: branding.agent_name ?? d.brand.name,
icon: d.brand.icon,
prompt: branding.prompt_symbol ?? d.brand.prompt,
prompt: cleanPromptSymbol(branding.prompt_symbol, d.brand.prompt),
welcome: branding.welcome ?? d.brand.welcome,
goodbye: branding.goodbye ?? d.brand.goodbye,
tool: toolPrefix || d.brand.tool,

View File

@@ -108,7 +108,7 @@ export interface ClarifyReq {
export interface Msg {
info?: SessionInfo
kind?: 'diff' | 'intro' | 'panel' | 'slash' | 'trail'
kind?: 'diff' | 'intro' | 'learning' | 'panel' | 'slash' | 'trail'
panelData?: PanelData
role: Role
text: string
@@ -148,6 +148,7 @@ export interface SessionInfo {
reasoning_effort?: string
service_tier?: string
release_date?: string
learning?: LearningSummary
skills: Record<string, string[]>
tools: Record<string, string[]>
update_behind?: number | null
@@ -156,6 +157,12 @@ export interface SessionInfo {
version?: string
}
export interface LearningSummary {
counts?: Record<string, number>
inventory?: { skills?: number }
total?: number
}
export interface Usage {
calls: number
context_max?: number