feat: just more cleaning

This commit is contained in:
Brooklyn Nicholson 2026-04-15 14:14:01 -05:00
parent 46cef4b7fa
commit 4b4b4d47bc
24 changed files with 2852 additions and 829 deletions

View file

@ -155,7 +155,7 @@ def _strip_blocked_tools(toolsets: List[str]) -> List[str]:
return [t for t in toolsets if t not in blocked_toolset_names]
def _build_child_progress_callback(task_index: int, parent_agent, task_count: int = 1) -> Optional[callable]:
def _build_child_progress_callback(task_index: int, goal: str, parent_agent, task_count: int = 1) -> Optional[callable]:
"""Build a callback that relays child agent tool calls to the parent display.
Two display paths:
@ -173,14 +173,46 @@ def _build_child_progress_callback(task_index: int, parent_agent, task_count: in
# Show 1-indexed prefix only in batch mode (multiple tasks)
prefix = f"[{task_index + 1}] " if task_count > 1 else ""
goal_label = (goal or "").strip()
# Gateway: batch tool names, flush periodically
_BATCH_SIZE = 5
_batch: List[str] = []
def _relay(event_type: str, tool_name: str = None, preview: str = None, args=None, **kwargs):
if not parent_cb:
return
try:
parent_cb(
event_type,
tool_name,
preview,
args,
task_index=task_index,
task_count=task_count,
goal=goal_label,
**kwargs,
)
except Exception as e:
logger.debug("Parent callback failed: %s", e)
def _callback(event_type: str, tool_name: str = None, preview: str = None, args=None, **kwargs):
# event_type is one of: "tool.started", "tool.completed",
# "reasoning.available", "_thinking", "subagent_progress"
# "reasoning.available", "_thinking", "subagent.*"
if event_type == "subagent.start":
if spinner and goal_label:
short = (goal_label[:55] + "...") if len(goal_label) > 55 else goal_label
try:
spinner.print_above(f" {prefix}├─ 🔀 {short}")
except Exception as e:
logger.debug("Spinner print_above failed: %s", e)
_relay("subagent.start", preview=preview or goal_label or "", **kwargs)
return
if event_type == "subagent.complete":
_relay("subagent.complete", preview=preview, **kwargs)
return
# "_thinking" / reasoning events
if event_type in ("_thinking", "reasoning.available"):
@ -191,7 +223,7 @@ def _build_child_progress_callback(task_index: int, parent_agent, task_count: in
spinner.print_above(f" {prefix}├─ 💭 \"{short}\"")
except Exception as e:
logger.debug("Spinner print_above failed: %s", e)
# Don't relay thinking to gateway (too noisy for chat)
_relay("subagent.thinking", preview=text)
return
# tool.completed — no display needed here (spinner shows on started)
@ -212,23 +244,18 @@ def _build_child_progress_callback(task_index: int, parent_agent, task_count: in
logger.debug("Spinner print_above failed: %s", e)
if parent_cb:
_relay("subagent.tool", tool_name, preview, args)
_batch.append(tool_name or "")
if len(_batch) >= _BATCH_SIZE:
summary = ", ".join(_batch)
try:
parent_cb("subagent_progress", f"🔀 {prefix}{summary}")
except Exception as e:
logger.debug("Parent callback failed: %s", e)
_relay("subagent.progress", preview=f"🔀 {prefix}{summary}")
_batch.clear()
def _flush():
"""Flush remaining batched tool names to gateway on completion."""
if parent_cb and _batch:
summary = ", ".join(_batch)
try:
parent_cb("subagent_progress", f"🔀 {prefix}{summary}")
except Exception as e:
logger.debug("Parent callback flush failed: %s", e)
_relay("subagent.progress", preview=f"🔀 {prefix}{summary}")
_batch.clear()
_callback._flush = _flush
@ -242,6 +269,7 @@ def _build_child_agent(
toolsets: Optional[List[str]],
model: Optional[str],
max_iterations: int,
task_count: int,
parent_agent,
# Credential overrides from delegation config (provider:model resolution)
override_provider: Optional[str] = None,
@ -298,7 +326,7 @@ def _build_child_agent(
parent_api_key = parent_agent._client_kwargs.get("api_key")
# Build progress callback to relay tool calls to parent display
child_progress_cb = _build_child_progress_callback(task_index, parent_agent)
child_progress_cb = _build_child_progress_callback(task_index, goal, parent_agent, task_count)
# Each subagent gets its own iteration budget capped at max_iterations
# (configurable via delegation.max_iterations, default 50). This means
@ -469,6 +497,12 @@ def _run_single_child(
_heartbeat_thread.start()
try:
if child_progress_cb:
try:
child_progress_cb("subagent.start", preview=goal)
except Exception as e:
logger.debug("Progress callback start failed: %s", e)
result = child.run_conversation(user_message=goal)
# Flush any remaining batched progress to gateway
@ -563,11 +597,34 @@ def _run_single_child(
if status == "failed":
entry["error"] = result.get("error", "Subagent did not produce a response.")
if child_progress_cb:
try:
child_progress_cb(
"subagent.complete",
preview=summary[:160] if summary else entry.get("error", ""),
status=status,
duration_seconds=duration,
summary=summary[:500] if summary else entry.get("error", ""),
)
except Exception as e:
logger.debug("Progress callback completion failed: %s", e)
return entry
except Exception as exc:
duration = round(time.monotonic() - child_start, 2)
logging.exception(f"[subagent-{task_index}] failed")
if child_progress_cb:
try:
child_progress_cb(
"subagent.complete",
preview=str(exc),
status="failed",
duration_seconds=duration,
summary=str(exc),
)
except Exception as e:
logger.debug("Progress callback failure relay failed: %s", e)
return {
"task_index": task_index,
"status": "error",
@ -714,7 +771,7 @@ def delegate_task(
child = _build_child_agent(
task_index=i, goal=t["goal"], context=t.get("context"),
toolsets=t.get("toolsets") or toolsets, model=creds["model"],
max_iterations=effective_max_iter, parent_agent=parent_agent,
max_iterations=effective_max_iter, task_count=n_tasks, parent_agent=parent_agent,
override_provider=creds["provider"], override_base_url=creds["base_url"],
override_api_key=creds["api_key"],
override_api_mode=creds["api_mode"],

View file

@ -1,6 +1,8 @@
import atexit
import copy
import json
import os
import queue
import subprocess
import sys
import threading
@ -29,6 +31,10 @@ _pending: dict[str, threading.Event] = {}
_answers: dict[str, str] = {}
_db = None
_stdout_lock = threading.Lock()
_cfg_lock = threading.Lock()
_cfg_cache: dict | None = None
_cfg_mtime: float | None = None
_SLASH_WORKER_TIMEOUT_S = max(5.0, float(os.environ.get("HERMES_TUI_SLASH_TIMEOUT_S", "45") or 45))
# Reserve real stdout for JSON-RPC only; redirect Python's stdout to stderr
# so stray print() from libraries/tools becomes harmless gateway.stderr instead
@ -44,6 +50,7 @@ class _SlashWorker:
self._lock = threading.Lock()
self._seq = 0
self.stderr_tail: list[str] = []
self.stdout_queue: queue.Queue[dict | None] = queue.Queue()
argv = [sys.executable, "-m", "tui_gateway.slash_worker", "--session-key", session_key]
if model:
@ -53,8 +60,17 @@ class _SlashWorker:
argv, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, bufsize=1, cwd=os.getcwd(), env=os.environ.copy(),
)
threading.Thread(target=self._drain_stdout, daemon=True).start()
threading.Thread(target=self._drain_stderr, daemon=True).start()
def _drain_stdout(self):
for line in (self.proc.stdout or []):
try:
self.stdout_queue.put(json.loads(line))
except json.JSONDecodeError:
continue
self.stdout_queue.put(None)
def _drain_stderr(self):
for line in (self.proc.stderr or []):
if text := line.rstrip("\n"):
@ -70,11 +86,13 @@ class _SlashWorker:
self.proc.stdin.write(json.dumps({"id": rid, "command": command}) + "\n")
self.proc.stdin.flush()
for line in self.proc.stdout:
while True:
try:
msg = json.loads(line)
except json.JSONDecodeError:
continue
msg = self.stdout_queue.get(timeout=_SLASH_WORKER_TIMEOUT_S)
except queue.Empty:
raise RuntimeError("slash worker timed out")
if msg is None:
break
if msg.get("id") != rid:
continue
if not msg.get("ok"):
@ -199,21 +217,58 @@ def _normalize_completion_path(path_part: str) -> str:
# ── Config I/O ────────────────────────────────────────────────────────
def _load_cfg() -> dict:
global _cfg_cache, _cfg_mtime
try:
import yaml
p = _hermes_home / "config.yaml"
mtime = p.stat().st_mtime if p.exists() else None
with _cfg_lock:
if _cfg_cache is not None and _cfg_mtime == mtime:
return copy.deepcopy(_cfg_cache)
if p.exists():
with open(p) as f:
return yaml.safe_load(f) or {}
data = yaml.safe_load(f) or {}
else:
data = {}
with _cfg_lock:
_cfg_cache = copy.deepcopy(data)
_cfg_mtime = mtime
return data
except Exception:
pass
return {}
def _save_cfg(cfg: dict):
global _cfg_cache, _cfg_mtime
import yaml
with open(_hermes_home / "config.yaml", "w") as f:
path = _hermes_home / "config.yaml"
with open(path, "w") as f:
yaml.safe_dump(cfg, f)
with _cfg_lock:
_cfg_cache = copy.deepcopy(cfg)
try:
_cfg_mtime = path.stat().st_mtime
except Exception:
_cfg_mtime = None
def _set_session_context(session_key: str) -> list:
try:
from gateway.session_context import set_session_vars
return set_session_vars(session_key=session_key)
except Exception:
return []
def _clear_session_context(tokens: list) -> None:
if not tokens:
return
try:
from gateway.session_context import clear_session_vars
clear_session_vars(tokens)
except Exception:
pass
# ── Blocking prompt factory ──────────────────────────────────────────
@ -307,6 +362,17 @@ def _load_tool_progress_mode() -> str:
return mode if mode in {"off", "new", "all", "verbose"} else "all"
def _load_enabled_toolsets() -> list[str] | None:
try:
from hermes_cli.config import load_config
from hermes_cli.tools_config import _get_platform_tools
enabled = sorted(_get_platform_tools(load_config(), "cli", include_default_mcp_servers=False))
return enabled or None
except Exception:
return None
def _session_show_reasoning(sid: str) -> bool:
return bool(_sessions.get(sid, {}).get("show_reasoning", False))
@ -626,6 +692,27 @@ def _on_tool_progress(
return
if event_type == "reasoning.available" and preview and _reasoning_visible(sid):
_emit("reasoning.available", sid, {"text": str(preview)})
return
if event_type.startswith("subagent."):
payload = {
"goal": str(_kwargs.get("goal") or ""),
"task_count": int(_kwargs.get("task_count") or 1),
"task_index": int(_kwargs.get("task_index") or 0),
}
if name:
payload["tool_name"] = str(name)
if preview:
payload["text"] = str(preview)
if _kwargs.get("status"):
payload["status"] = str(_kwargs["status"])
if _kwargs.get("summary"):
payload["summary"] = str(_kwargs["summary"])
if _kwargs.get("duration_seconds") is not None:
payload["duration_seconds"] = float(_kwargs["duration_seconds"])
if preview and event_type == "subagent.tool":
payload["tool_preview"] = str(preview)
payload["text"] = str(preview)
_emit(event_type, sid, payload)
def _agent_cbs(sid: str) -> dict:
@ -735,14 +822,7 @@ def _apply_personality_to_session(sid: str, session: dict, new_prompt: str) -> t
return False, None
try:
new_agent = _make_agent(sid, session["session_key"], session_id=session["session_key"])
session["agent"] = new_agent
with session["history_lock"]:
session["history"] = []
session["history_version"] = int(session.get("history_version", 0)) + 1
info = _session_info(new_agent)
_emit("session.info", sid, info)
_restart_slash_worker(session)
info = _reset_session_agent(sid, session)
return True, info
except Exception:
if session.get("agent"):
@ -755,6 +835,61 @@ def _apply_personality_to_session(sid: str, session: dict, new_prompt: str) -> t
return False, None
def _background_agent_kwargs(agent, task_id: str) -> dict:
cfg = _load_cfg()
return {
"base_url": getattr(agent, "base_url", None) or None,
"api_key": getattr(agent, "api_key", None) or None,
"provider": getattr(agent, "provider", None) or None,
"api_mode": getattr(agent, "api_mode", None) or None,
"acp_command": getattr(agent, "acp_command", None) or None,
"acp_args": getattr(agent, "acp_args", None) or None,
"model": getattr(agent, "model", None) or _resolve_model(),
"max_iterations": int(cfg.get("max_turns", 25) or 25),
"enabled_toolsets": getattr(agent, "enabled_toolsets", None) or _load_enabled_toolsets(),
"quiet_mode": True,
"verbose_logging": False,
"ephemeral_system_prompt": getattr(agent, "ephemeral_system_prompt", None) or None,
"providers_allowed": getattr(agent, "providers_allowed", None),
"providers_ignored": getattr(agent, "providers_ignored", None),
"providers_order": getattr(agent, "providers_order", None),
"provider_sort": getattr(agent, "provider_sort", None),
"provider_require_parameters": getattr(agent, "provider_require_parameters", False),
"provider_data_collection": getattr(agent, "provider_data_collection", None),
"session_id": task_id,
"reasoning_config": getattr(agent, "reasoning_config", None) or _load_reasoning_config(),
"service_tier": getattr(agent, "service_tier", None) or _load_service_tier(),
"request_overrides": dict(getattr(agent, "request_overrides", {}) or {}),
"platform": "tui",
"session_db": _get_db(),
"fallback_model": getattr(agent, "_fallback_model", None),
}
def _reset_session_agent(sid: str, session: dict) -> dict:
tokens = _set_session_context(session["session_key"])
try:
new_agent = _make_agent(sid, session["session_key"], session_id=session["session_key"])
finally:
_clear_session_context(tokens)
session["agent"] = new_agent
session["attached_images"] = []
session["edit_snapshots"] = {}
session["image_counter"] = 0
session["running"] = False
session["show_reasoning"] = _load_show_reasoning()
session["tool_progress_mode"] = _load_tool_progress_mode()
session["tool_started_at"] = {}
with session["history_lock"]:
session["history"] = []
session["history_version"] = int(session.get("history_version", 0)) + 1
info = _session_info(new_agent)
_emit("session.info", sid, info)
_restart_slash_worker(session)
return info
def _make_agent(sid: str, key: str, session_id: str | None = None):
from run_agent import AIAgent
cfg = _load_cfg()
@ -767,6 +902,7 @@ def _make_agent(sid: str, key: str, session_id: str | None = None):
verbose_logging=_load_tool_progress_mode() == "verbose",
reasoning_config=_load_reasoning_config(),
service_tier=_load_service_tier(),
enabled_toolsets=_load_enabled_toolsets(),
platform="tui",
session_id=session_id or key, session_db=_get_db(),
ephemeral_system_prompt=system_prompt or None,
@ -857,16 +993,55 @@ def _enrich_with_attached_images(user_text: str, image_paths: list[str]) -> str:
return text or "What do you see in this image?"
def _history_to_messages(history: list[dict]) -> list[dict]:
messages = []
tool_call_args = {}
for m in history:
if not isinstance(m, dict):
continue
role = m.get("role")
if role not in ("user", "assistant", "tool", "system"):
continue
if role == "assistant" and m.get("tool_calls"):
for tc in m["tool_calls"]:
fn = tc.get("function", {})
tc_id = tc.get("id", "")
if tc_id and fn.get("name"):
try:
args = json.loads(fn.get("arguments", "{}"))
except (json.JSONDecodeError, TypeError):
args = {}
tool_call_args[tc_id] = (fn["name"], args)
if not (m.get("content") or "").strip():
continue
if role == "tool":
tc_id = m.get("tool_call_id", "")
tc_info = tool_call_args.get(tc_id) if tc_id else None
name = (tc_info[0] if tc_info else None) or m.get("tool_name") or "tool"
args = (tc_info[1] if tc_info else None) or {}
messages.append({"role": "tool", "name": name, "context": _tool_ctx(name, args)})
continue
if not (m.get("content") or "").strip():
continue
messages.append({"role": role, "text": m.get("content") or ""})
return messages
# ── Methods: session ─────────────────────────────────────────────────
@method("session.create")
def _(rid, params: dict) -> dict:
sid = uuid.uuid4().hex[:8]
key = _new_session_key()
os.environ["HERMES_SESSION_KEY"] = key
os.environ["HERMES_INTERACTIVE"] = "1"
try:
agent = _make_agent(sid, key)
tokens = _set_session_context(key)
try:
agent = _make_agent(sid, key)
finally:
_clear_session_context(tokens)
_get_db().create_session(key, source="tui", model=_resolve_model())
_init_session(sid, key, agent, [], cols=int(params.get("cols", 80)))
except Exception as e:
@ -911,41 +1086,16 @@ def _(rid, params: dict) -> dict:
else:
return _err(rid, 4007, "session not found")
sid = uuid.uuid4().hex[:8]
os.environ["HERMES_SESSION_KEY"] = target
os.environ["HERMES_INTERACTIVE"] = "1"
try:
db.reopen_session(target)
history = db.get_messages_as_conversation(target)
messages = []
tool_call_args = {}
for m in history:
role = m.get("role")
if role not in ("user", "assistant", "tool", "system"):
continue
if role == "assistant" and m.get("tool_calls"):
for tc in m["tool_calls"]:
fn = tc.get("function", {})
tc_id = tc.get("id", "")
if tc_id and fn.get("name"):
try:
args = json.loads(fn.get("arguments", "{}"))
except (json.JSONDecodeError, TypeError):
args = {}
tool_call_args[tc_id] = (fn["name"], args)
if not (m.get("content") or "").strip():
continue
if role == "tool":
tc_id = m.get("tool_call_id", "")
tc_info = tool_call_args.get(tc_id) if tc_id else None
name = (tc_info[0] if tc_info else None) or m.get("tool_name") or "tool"
args = (tc_info[1] if tc_info else None) or {}
ctx = _tool_ctx(name, args)
messages.append({"role": "tool", "name": name, "context": ctx})
continue
if not (m.get("content") or "").strip():
continue
messages.append({"role": role, "text": m.get("content") or ""})
agent = _make_agent(sid, target, session_id=target)
messages = _history_to_messages(history)
tokens = _set_session_context(target)
try:
agent = _make_agent(sid, target, session_id=target)
finally:
_clear_session_context(tokens)
_init_session(sid, target, agent, history, cols=int(params.get("cols", 80)))
except Exception as e:
return _err(rid, 5000, f"resume failed: {e}")
@ -985,7 +1135,13 @@ def _(rid, params: dict) -> dict:
@method("session.history")
def _(rid, params: dict) -> dict:
session, err = _sess(params, rid)
return err or _ok(rid, {"count": len(session.get("history", []))})
return err or _ok(
rid,
{
"count": len(session.get("history", [])),
"messages": _history_to_messages(list(session.get("history", []))),
},
)
@method("session.undo")
@ -1086,9 +1242,12 @@ def _(rid, params: dict) -> dict:
except Exception as e:
return _err(rid, 5008, f"branch failed: {e}")
new_sid = uuid.uuid4().hex[:8]
os.environ["HERMES_SESSION_KEY"] = new_key
try:
agent = _make_agent(new_sid, new_key, session_id=new_key)
tokens = _set_session_context(new_key)
try:
agent = _make_agent(new_sid, new_key, session_id=new_key)
finally:
_clear_session_context(tokens)
_init_session(new_sid, new_key, agent, list(history), cols=session.get("cols", 80))
except Exception as e:
return _err(rid, 5000, f"agent init failed on branch: {e}")
@ -1141,9 +1300,11 @@ def _(rid, params: dict) -> dict:
def run():
approval_token = None
session_tokens = []
try:
from tools.approval import reset_current_session_key, set_current_session_key
approval_token = set_current_session_key(session["session_key"])
session_tokens = _set_session_context(session["session_key"])
cols = session.get("cols", 80)
streamer = make_stream_renderer(cols)
prompt = text
@ -1206,6 +1367,7 @@ def _(rid, params: dict) -> dict:
reset_current_session_key(approval_token)
except Exception:
pass
_clear_session_context(session_tokens)
with session["history_lock"]:
session["running"] = False
@ -1336,14 +1498,19 @@ def _(rid, params: dict) -> dict:
task_id = f"bg_{uuid.uuid4().hex[:6]}"
def run():
session_tokens = _set_session_context(task_id)
try:
from run_agent import AIAgent
result = AIAgent(model=_resolve_model(), quiet_mode=True, platform="tui",
session_id=task_id, max_iterations=30).run_conversation(text)
result = AIAgent(**_background_agent_kwargs(session["agent"], task_id)).run_conversation(
user_message=text,
task_id=task_id,
)
_emit("background.complete", parent, {"task_id": task_id,
"text": result.get("final_response", str(result)) if isinstance(result, dict) else str(result)})
except Exception as e:
_emit("background.complete", parent, {"task_id": task_id, "text": f"error: {e}"})
finally:
_clear_session_context(session_tokens)
threading.Thread(target=run, daemon=True).start()
return _ok(rid, {"task_id": task_id})
@ -1360,6 +1527,7 @@ def _(rid, params: dict) -> dict:
snapshot = list(session.get("history", []))
def run():
session_tokens = _set_session_context(session["session_key"])
try:
from run_agent import AIAgent
result = AIAgent(model=_resolve_model(), quiet_mode=True, platform="tui",
@ -1367,6 +1535,8 @@ def _(rid, params: dict) -> dict:
_emit("btw.complete", sid, {"text": result.get("final_response", str(result)) if isinstance(result, dict) else str(result)})
except Exception as e:
_emit("btw.complete", sid, {"text": f"error: {e}"})
finally:
_clear_session_context(session_tokens)
threading.Thread(target=run, daemon=True).start()
return _ok(rid, {"status": "running"})
@ -1637,8 +1807,8 @@ def _(rid, params: dict) -> dict:
@method("process.stop")
def _(rid, params: dict) -> dict:
try:
from tools.process_registry import ProcessRegistry
return _ok(rid, {"killed": ProcessRegistry().kill_all()})
from tools.process_registry import process_registry
return _ok(rid, {"killed": process_registry.kill_all()})
except Exception as e:
return _err(rid, 5010, str(e))
@ -2036,8 +2206,8 @@ def _mirror_slash_side_effects(sid: str, session: dict, command: str) -> str:
elif name == "reload-mcp" and agent and hasattr(agent, "reload_mcp_tools"):
agent.reload_mcp_tools()
elif name == "stop":
from tools.process_registry import ProcessRegistry
ProcessRegistry().kill_all()
from tools.process_registry import process_registry
process_registry.kill_all()
except Exception as e:
return f"live session sync failed: {e}"
return ""
@ -2315,9 +2485,7 @@ def _(rid, params: dict) -> dict:
try:
from toolsets import get_all_toolsets, get_toolset_info
session = _sessions.get(params.get("session_id", ""))
enabled = set()
if session:
enabled = set(getattr(session["agent"], "enabled_toolsets", []) or [])
enabled = set(getattr(session["agent"], "enabled_toolsets", []) or []) if session else set(_load_enabled_toolsets() or [])
items = []
for name in sorted(get_all_toolsets().keys()):
@ -2336,14 +2504,92 @@ def _(rid, params: dict) -> dict:
return _err(rid, 5031, str(e))
@method("tools.show")
def _(rid, params: dict) -> dict:
try:
from model_tools import get_toolset_for_tool, get_tool_definitions
session = _sessions.get(params.get("session_id", ""))
enabled = getattr(session["agent"], "enabled_toolsets", None) if session else _load_enabled_toolsets()
tools = get_tool_definitions(enabled_toolsets=enabled, quiet_mode=True)
sections = {}
for tool in sorted(tools, key=lambda t: t["function"]["name"]):
name = tool["function"]["name"]
desc = str(tool["function"].get("description", "") or "").split("\n")[0]
if ". " in desc:
desc = desc[:desc.index(". ") + 1]
sections.setdefault(get_toolset_for_tool(name) or "unknown", []).append({
"name": name,
"description": desc,
})
return _ok(rid, {
"sections": [{"name": name, "tools": rows} for name, rows in sorted(sections.items())],
"total": len(tools),
})
except Exception as e:
return _err(rid, 5034, str(e))
@method("tools.configure")
def _(rid, params: dict) -> dict:
action = str(params.get("action", "") or "").strip().lower()
targets = [str(name).strip() for name in params.get("names", []) or [] if str(name).strip()]
if action not in {"disable", "enable"}:
return _err(rid, 4017, f"unknown tools action: {action}")
if not targets:
return _err(rid, 4018, "names required")
try:
from hermes_cli.config import load_config, save_config
from hermes_cli.tools_config import (
CONFIGURABLE_TOOLSETS,
_apply_mcp_change,
_apply_toolset_change,
_get_platform_tools,
_get_plugin_toolset_keys,
)
cfg = load_config()
valid_toolsets = {ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS} | _get_plugin_toolset_keys()
toolset_targets = [name for name in targets if ":" not in name]
mcp_targets = [name for name in targets if ":" in name]
unknown = [name for name in toolset_targets if name not in valid_toolsets]
toolset_targets = [name for name in toolset_targets if name in valid_toolsets]
if toolset_targets:
_apply_toolset_change(cfg, "cli", toolset_targets, action)
missing_servers = _apply_mcp_change(cfg, mcp_targets, action) if mcp_targets else set()
save_config(cfg)
session = _sessions.get(params.get("session_id", ""))
info = _reset_session_agent(params.get("session_id", ""), session) if session else None
enabled = sorted(_get_platform_tools(load_config(), "cli", include_default_mcp_servers=False))
changed = [
name for name in targets
if name not in unknown and (":" not in name or name.split(":", 1)[0] not in missing_servers)
]
return _ok(rid, {
"changed": changed,
"enabled_toolsets": enabled,
"info": info,
"missing_servers": sorted(missing_servers),
"reset": bool(session),
"unknown": unknown,
})
except Exception as e:
return _err(rid, 5035, str(e))
@method("toolsets.list")
def _(rid, params: dict) -> dict:
try:
from toolsets import get_all_toolsets, get_toolset_info
session = _sessions.get(params.get("session_id", ""))
enabled = set()
if session:
enabled = set(getattr(session["agent"], "enabled_toolsets", []) or [])
enabled = set(getattr(session["agent"], "enabled_toolsets", []) or []) if session else set(_load_enabled_toolsets() or [])
items = []
for name in sorted(get_all_toolsets().keys()):
@ -2364,8 +2610,8 @@ def _(rid, params: dict) -> dict:
@method("agents.list")
def _(rid, params: dict) -> dict:
try:
from tools.process_registry import ProcessRegistry
procs = ProcessRegistry().list_sessions()
from tools.process_registry import process_registry
procs = process_registry.list_sessions()
return _ok(rid, {
"processes": [{
"session_id": p["session_id"],

View file

@ -75,8 +75,7 @@ describe('createGatewayEventHandler', () => {
},
transcript: {
appendMessage: (msg: Msg) => appended.push(msg),
setHistoryItems: vi.fn(),
setMessages: vi.fn()
setHistoryItems: vi.fn()
},
turn: {
actions: {
@ -191,8 +190,7 @@ describe('createGatewayEventHandler', () => {
},
transcript: {
appendMessage: (msg: Msg) => appended.push(msg),
setHistoryItems: vi.fn(),
setMessages: vi.fn()
setHistoryItems: vi.fn()
},
turn: {
actions: {
@ -304,8 +302,7 @@ describe('createGatewayEventHandler', () => {
},
transcript: {
appendMessage: (msg: Msg) => appended.push(msg),
setHistoryItems: vi.fn(),
setMessages: vi.fn()
setHistoryItems: vi.fn()
},
turn: {
actions: {

View file

@ -0,0 +1,179 @@
import { describe, expect, it } from 'vitest'
import { DEFAULT_THEME } from '../theme.js'
import type { WidgetSpec } from '../widgets.js'
import {
bloombergTheme,
buildWidgets,
cityTime,
livePoints,
marquee,
plotLineRows,
sparkline,
widgetsInRegion,
wrapWindow
} from '../widgets.js'
const BASE_CTX = {
bgCount: 0,
busy: false,
cols: 120,
cwdLabel: '~/hermes-agent',
durationLabel: '11s',
model: 'claude',
status: 'idle',
t: DEFAULT_THEME,
usage: { calls: 0, input: 0, output: 0, total: 0 },
voiceLabel: 'voice off'
}
describe('sparkline', () => {
it('respects requested width', () => {
expect([...sparkline([1, 2, 3, 4, 5], 3)]).toHaveLength(3)
})
it('is stable for flat series', () => {
const line = sparkline([7, 7, 7, 7], 4)
expect([...line]).toHaveLength(4)
expect(new Set([...line]).size).toBe(1)
})
})
describe('widgetsInRegion', () => {
it('filters and sorts by region + order', () => {
const widgets: WidgetSpec[] = [
{ id: 'c', node: 'c', order: 20, region: 'dock' },
{ id: 'a', node: 'a', order: 5, region: 'dock' },
{ id: 'b', node: 'b', order: 1, region: 'overlay' }
]
expect(widgetsInRegion(widgets, 'dock').map(w => w.id)).toEqual(['a', 'c'])
})
})
describe('wrapWindow', () => {
it('wraps around the array', () => {
expect(wrapWindow([1, 2, 3], 2, 5)).toEqual([3, 1, 2, 3, 1])
})
})
describe('marquee', () => {
it('returns fixed-width slice', () => {
expect(marquee('abc', 0, 5)).toHaveLength(5)
expect(marquee('abc', 1, 5)).not.toEqual(marquee('abc', 0, 5))
})
})
describe('plotLineRows', () => {
it('returns the requested height', () => {
expect(plotLineRows([1, 2, 3, 4], 4, 3)).toHaveLength(3)
})
it('each row has the requested width', () => {
const rows = plotLineRows([1, 4, 2, 5], 20, 4)
for (const row of rows) {
expect([...row]).toHaveLength(20)
}
})
it('draws visible braille for varied data', () => {
expect(plotLineRows([1, 4, 2, 5], 8, 3).join('')).toMatch(/[^\u2800 ]/)
})
})
describe('livePoints', () => {
const asset = { label: 'TEST', series: [100, 102, 101, 103, 105] }
it('extends the series by one', () => {
expect(livePoints(asset, 0)).toHaveLength(asset.series.length + 1)
})
it('starts at series[0]', () => {
expect(livePoints(asset, 0)[0]).toBe(100)
})
it('live point stays within ±2% of last value', () => {
const last = asset.series.at(-1)!
for (let t = 0; t < 50; t++) {
const pts = livePoints(asset, t)
const live = pts.at(-1)!
expect(live).toBeGreaterThan(last * 0.98)
expect(live).toBeLessThan(last * 1.02)
}
})
})
describe('cityTime', () => {
it('returns HH:MM:SS format', () => {
expect(cityTime('America/New_York')).toMatch(/^\d{2}:\d{2}:\d{2}$/)
})
it('works for all clock zones', () => {
for (const tz of ['America/New_York', 'Europe/London', 'Asia/Tokyo', 'Australia/Sydney']) {
expect(cityTime(tz)).toMatch(/^\d{2}:\d{2}:\d{2}$/)
}
})
})
describe('buildWidgets', () => {
it('routes widgets into dock + sidebar regions', () => {
const widgets = buildWidgets({ ...BASE_CTX, blocked: true })
const byId = new Map(widgets.map(w => [w.id, w.region]))
expect(byId.get('ticker')).toBe('dock')
expect(byId.get('world-clock')).toBe('sidebar')
expect(byId.get('weather')).toBe('sidebar')
expect(byId.get('heartbeat')).toBe('sidebar')
})
it('filters by enabled map', () => {
const enabled = { ticker: true, 'world-clock': false, weather: true, heartbeat: false }
const widgets = buildWidgets(BASE_CTX, { enabled })
const ids = widgets.map(w => w.id)
expect(ids).toContain('ticker')
expect(ids).toContain('weather')
expect(ids).not.toContain('world-clock')
expect(ids).not.toContain('heartbeat')
})
it('accepts widget params config', () => {
const widgets = buildWidgets(BASE_CTX, {
enabled: { ticker: true, 'world-clock': true, weather: true, heartbeat: true },
params: { ticker: { asset: 'ETH' } }
})
expect(widgets.map(w => w.id)).toContain('ticker')
})
it('returns all when no enabled map given', () => {
const widgets = buildWidgets({ ...BASE_CTX, blocked: false })
expect(widgets.some(w => w.region === 'overlay')).toBe(false)
expect(widgets.some(w => w.region === 'dock')).toBe(true)
})
it('includes all expected widget ids', () => {
const ids = buildWidgets(BASE_CTX).map(w => w.id)
expect(ids).toContain('ticker')
expect(ids).toContain('weather')
expect(ids).toContain('world-clock')
expect(ids).toContain('heartbeat')
})
})
describe('bloombergTheme', () => {
it('overrides color keys while preserving brand', () => {
const bt = bloombergTheme(DEFAULT_THEME)
expect(bt.brand).toEqual(DEFAULT_THEME.brand)
expect(bt.color.cornsilk).toBe('#FFFFFF')
expect(bt.color.statusGood).toBe('#00EE00')
expect(bt.color.statusBad).toBe('#FF2200')
})
})

View file

@ -7,7 +7,6 @@ import { createGatewayEventHandler } from './app/createGatewayEventHandler.js'
import { createSlashHandler } from './app/createSlashHandler.js'
import { GatewayProvider } from './app/gatewayContext.js'
import {
fmtDuration,
imageTokenMeta,
introMsg,
looksLikeSlashCommand,
@ -15,7 +14,7 @@ import {
shortCwd,
toTranscriptMessages
} from './app/helpers.js'
import { type TranscriptRow } from './app/interfaces.js'
import { type GatewayRpc, type TranscriptRow } from './app/interfaces.js'
import { $isBlocked, $overlayState, patchOverlayState } from './app/overlayStore.js'
import { $uiState, getUiState, patchUiState } from './app/uiStore.js'
import { useComposerState } from './app/useComposerState.js'
@ -23,7 +22,8 @@ import { useInputHandlers } from './app/useInputHandlers.js'
import { useTurnState } from './app/useTurnState.js'
import { AppLayout } from './components/appLayout.js'
import { INTERPOLATION_RE, ZERO } from './constants.js'
import { type GatewayClient, type GatewayEvent } from './gatewayClient.js'
import { type GatewayClient } from './gatewayClient.js'
import type { ConfigFullResponse, ConfigMtimeResponse, GatewayEvent, SessionCreateResponse } from './gatewayTypes.js'
import { useVirtualHistory } from './hooks/useVirtualHistory.js'
import { asRpcResult, rpcErrorMessage } from './lib/rpc.js'
import { buildToolTrailLine, hasInterpolation, sameToolTrailGroup, toolTrailLabel } from './lib/text.js'
@ -60,7 +60,6 @@ export function App({ gw }: { gw: GatewayClient }) {
// ── State ────────────────────────────────────────────────────────
const [messages, setMessages] = useState<Msg[]>([])
const [historyItems, setHistoryItems] = useState<Msg[]>([])
const [lastUserMsg, setLastUserMsg] = useState('')
const [stickyPrompt, setStickyPrompt] = useState('')
@ -70,7 +69,6 @@ export function App({ gw }: { gw: GatewayClient }) {
const [voiceProcessing, setVoiceProcessing] = useState(false)
const [sessionStartedAt, setSessionStartedAt] = useState(() => Date.now())
const [bellOnComplete, setBellOnComplete] = useState(false)
const [clockNow, setClockNow] = useState(() => Date.now())
const ui = useStore($uiState)
const overlay = useStore($overlayState)
const isBlocked = useStore($isBlocked)
@ -85,7 +83,13 @@ export function App({ gw }: { gw: GatewayClient }) {
const clipboardPasteRef = useRef<(quiet?: boolean) => Promise<void> | void>(() => {})
const submitRef = useRef<(value: string) => void>(() => {})
const configMtimeRef = useRef(0)
const historyItemsRef = useRef(historyItems)
const lastUserMsgRef = useRef(lastUserMsg)
const msgIdsRef = useRef(new WeakMap<Msg, string>())
const nextMsgIdRef = useRef(0)
colsRef.current = cols
historyItemsRef.current = historyItems
lastUserMsgRef.current = lastUserMsg
// ── Hooks ────────────────────────────────────────────────────────
@ -105,17 +109,36 @@ export function App({ gw }: { gw: GatewayClient }) {
const composerActions = composer.actions
const composerRefs = composer.refs
const composerState = composer.state
const composerCompletions = composerState.completions
const composerCompIdx = composerState.compIdx
const composerInput = composerState.input
const composerInputBuf = composerState.inputBuf
const composerQueueEditIdx = composerState.queueEditIdx
const composerQueuedDisplay = composerState.queuedDisplay
const empty = !messages.length
const empty = !historyItems.some(msg => msg.kind !== 'intro')
const messageId = useCallback((msg: Msg) => {
const hit = msgIdsRef.current.get(msg)
if (hit) {
return hit
}
const next = `m${++nextMsgIdRef.current}`
msgIdsRef.current.set(msg, next)
return next
}, [])
const virtualRows = useMemo<TranscriptRow[]>(
() =>
historyItems.map((msg, index) => ({
index,
key: `${index}:${msg.role}:${msg.kind ?? ''}:${msg.text.slice(0, 40)}`,
key: messageId(msg),
msg
})),
[historyItems]
[historyItems, messageId]
)
const virtualHistory = useVirtualHistory(scrollRef, virtualRows)
@ -173,12 +196,6 @@ export function App({ gw }: { gw: GatewayClient }) {
[selection]
)
useEffect(() => {
const id = setInterval(() => setClockNow(Date.now()), 1000)
return () => clearInterval(id)
}, [])
// ── Core actions ─────────────────────────────────────────────────
const appendMessage = useCallback((msg: Msg) => {
@ -189,7 +206,6 @@ export function App({ gw }: { gw: GatewayClient }) {
? [items[0]!, ...items.slice(-(MAX_HISTORY - 1))]
: items.slice(-MAX_HISTORY)
setMessages(prev => cap([...prev, msg]))
setHistoryItems(prev => cap([...prev, msg]))
}, [])
@ -220,10 +236,24 @@ export function App({ gw }: { gw: GatewayClient }) {
const pruneTransient = turnActions.pruneTransient
const pushTrail = turnActions.pushTrail
const rpc = useCallback(
async (method: string, params: Record<string, unknown> = {}) => {
const applyDisplayConfig = useCallback((cfg: ConfigFullResponse | null) => {
const display = cfg?.config?.display ?? {}
setBellOnComplete(!!display?.bell_on_complete)
patchUiState({
compact: !!display?.tui_compact,
detailsMode: resolveDetailsMode(display),
statusBar: display?.tui_statusbar !== false
})
}, [])
const rpc: GatewayRpc = useCallback(
async <T extends Record<string, any> = Record<string, any>>(
method: string,
params: Record<string, unknown> = {}
) => {
try {
const result = asRpcResult(await gw.request(method, params))
const result = asRpcResult<T>(await gw.request<T>(method, params))
if (result) {
return result
@ -301,20 +331,11 @@ export function App({ gw }: { gw: GatewayClient }) {
}
rpc('voice.toggle', { action: 'status' }).then((r: any) => setVoiceEnabled(!!r?.enabled))
rpc('config.get', { key: 'mtime' }).then((r: any) => {
rpc<ConfigMtimeResponse>('config.get', { key: 'mtime' }).then(r => {
configMtimeRef.current = Number(r?.mtime ?? 0)
})
rpc('config.get', { key: 'full' }).then((r: any) => {
const display = r?.config?.display ?? {}
setBellOnComplete(!!display?.bell_on_complete)
patchUiState({
compact: !!display?.tui_compact,
detailsMode: resolveDetailsMode(display),
statusBar: display?.tui_statusbar !== false
})
})
}, [rpc, ui.sid])
rpc<ConfigFullResponse>('config.get', { key: 'full' }).then(applyDisplayConfig)
}, [applyDisplayConfig, rpc, ui.sid])
useEffect(() => {
if (!ui.sid) {
@ -322,7 +343,7 @@ export function App({ gw }: { gw: GatewayClient }) {
}
const id = setInterval(() => {
rpc('config.get', { key: 'mtime' }).then((r: any) => {
rpc<ConfigMtimeResponse>('config.get', { key: 'mtime' }).then(r => {
const next = Number(r?.mtime ?? 0)
if (configMtimeRef.current && next && next !== configMtimeRef.current) {
@ -334,16 +355,7 @@ export function App({ gw }: { gw: GatewayClient }) {
pushActivity('MCP reloaded after config change')
})
rpc('config.get', { key: 'full' }).then((cfg: any) => {
const display = cfg?.config?.display ?? {}
setBellOnComplete(!!display?.bell_on_complete)
patchUiState({
compact: !!display?.tui_compact,
detailsMode: resolveDetailsMode(display),
statusBar: display?.tui_statusbar !== false
})
})
rpc<ConfigFullResponse>('config.get', { key: 'full' }).then(applyDisplayConfig)
} else if (!configMtimeRef.current && next) {
configMtimeRef.current = next
}
@ -351,7 +363,7 @@ export function App({ gw }: { gw: GatewayClient }) {
}, 5000)
return () => clearInterval(id)
}, [pushActivity, rpc, ui.sid])
}, [applyDisplayConfig, pushActivity, rpc, ui.sid])
const idle = turnActions.idle
const clearReasoning = turnActions.clearReasoning
@ -373,7 +385,7 @@ export function App({ gw }: { gw: GatewayClient }) {
usage: ZERO
})
setHistoryItems([])
setMessages([])
setLastUserMsg('')
setStickyPrompt('')
composerActions.setPasteSnips([])
turnActions.setActivity([])
@ -387,7 +399,6 @@ export function App({ gw }: { gw: GatewayClient }) {
(info: SessionInfo | null = null) => {
idle()
clearReasoning()
setMessages([])
setHistoryItems(info ? [introMsg(info)] : [])
patchUiState({
info,
@ -403,7 +414,7 @@ export function App({ gw }: { gw: GatewayClient }) {
[clearReasoning, composerActions, idle, turnActions, turnRefs]
)
const trimLastExchange = (items: Msg[]) => {
const trimLastExchange = useCallback((items: Msg[]) => {
const q = [...items]
while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') {
@ -415,7 +426,7 @@ export function App({ gw }: { gw: GatewayClient }) {
}
return q
}
}, [])
const guardBusySessionSwitch = useCallback(
(what = 'switch sessions') => {
@ -447,7 +458,7 @@ export function App({ gw }: { gw: GatewayClient }) {
async (msg?: string) => {
await closeSession(getUiState().sid)
return rpc('session.create', { cols: colsRef.current }).then((r: any) => {
return rpc<SessionCreateResponse>('session.create', { cols: colsRef.current }).then(r => {
if (!r) {
patchUiState({ status: 'ready' })
@ -500,7 +511,6 @@ export function App({ gw }: { gw: GatewayClient }) {
setSessionStartedAt(Date.now())
const resumed = toTranscriptMessages(r.messages)
setMessages(resumed)
setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed)
patchUiState({
info: r.info ?? null,
@ -833,8 +843,7 @@ export function App({ gw }: { gw: GatewayClient }) {
},
transcript: {
appendMessage,
setHistoryItems,
setMessages
setHistoryItems
},
turn: {
actions: {
@ -850,6 +859,7 @@ export function App({ gw }: { gw: GatewayClient }) {
setActivity: turnActions.setActivity,
setReasoningTokens: turnActions.setReasoningTokens,
setStreaming: turnActions.setStreaming,
setSubagents: turnActions.setSubagents,
setToolTokens: turnActions.setToolTokens,
setTools: turnActions.setTools,
setTurnTrail: turnActions.setTurnTrail
@ -913,46 +923,73 @@ export function App({ gw }: { gw: GatewayClient }) {
}, [gw, pushActivity, sys])
// ── Slash commands ───────────────────────────────────────────────
// Always current via ref — no useMemo deps duplication needed.
slashRef.current = createSlashHandler({
composer: {
enqueue: composerActions.enqueue,
hasSelection,
paste,
queueRef: composerRefs.queueRef,
selection,
setInput: composerActions.setInput
},
gateway,
local: {
const slash = useMemo(
() =>
createSlashHandler({
composer: {
enqueue: composerActions.enqueue,
hasSelection,
paste,
queueRef: composerRefs.queueRef,
selection,
setInput: composerActions.setInput
},
gateway,
local: {
catalog,
getHistoryItems: () => historyItemsRef.current,
getLastUserMsg: () => lastUserMsgRef.current,
maybeWarn
},
session: {
closeSession,
die,
guardBusySessionSwitch,
newSession,
resetVisibleHistory,
resumeById,
setSessionStartedAt
},
transcript: {
page,
panel,
send,
setHistoryItems,
sys,
trimLastExchange
},
voice: {
setVoiceEnabled
}
}),
[
catalog,
lastUserMsg,
maybeWarn,
messages
},
session: {
closeSession,
composerActions,
composerRefs,
die,
gateway,
guardBusySessionSwitch,
hasSelection,
maybeWarn,
newSession,
resetVisibleHistory,
resumeById,
setSessionStartedAt
},
transcript: {
page,
panel,
paste,
resetVisibleHistory,
resumeById,
selection,
send,
setSessionStartedAt,
setHistoryItems,
setMessages,
setVoiceEnabled,
sys,
trimLastExchange
},
voice: {
setVoiceEnabled
}
})
]
)
slashRef.current = slash
// ── Submit ───────────────────────────────────────────────────────
@ -1033,7 +1070,7 @@ export function App({ gw }: { gw: GatewayClient }) {
? ui.theme.color.warn
: ui.theme.color.dim
const durationLabel = ui.sid ? fmtDuration(clockNow - sessionStartedAt) : ''
const sessionStarted = ui.sid ? sessionStartedAt : null
const voiceLabel = voiceRecording ? 'REC' : voiceProcessing ? 'STT' : `voice ${voiceEnabled ? 'on' : 'off'}`
const cwdLabel = shortCwd(ui.info?.cwd || process.env.HERMES_CWD || process.cwd())
const showStreamingArea = Boolean(turnState.streaming)
@ -1045,7 +1082,12 @@ export function App({ gw }: { gw: GatewayClient }) {
ui.detailsMode === 'hidden'
? turnState.activity.some(item => item.tone !== 'info')
: Boolean(
ui.busy || turnState.tools.length || turnState.turnTrail.length || hasReasoning || turnState.activity.length
ui.busy ||
turnState.subagents.length ||
turnState.tools.length ||
turnState.turnTrail.length ||
hasReasoning ||
turnState.activity.length
)
const answerApproval = useCallback(
@ -1104,62 +1146,101 @@ export function App({ gw }: { gw: GatewayClient }) {
slashRef.current(`/model ${value}`)
}, [])
const appActions = useMemo(
() => ({
answerApproval,
answerClarify,
answerSecret,
answerSudo,
onModelSelect,
resumeById,
setStickyPrompt
}),
[answerApproval, answerClarify, answerSecret, answerSudo, onModelSelect, resumeById, setStickyPrompt]
)
const appComposer = useMemo(
() => ({
cols,
compIdx: composerCompIdx,
completions: composerCompletions,
empty,
handleTextPaste,
input: composerInput,
inputBuf: composerInputBuf,
pagerPageSize,
queueEditIdx: composerQueueEditIdx,
queuedDisplay: composerQueuedDisplay,
submit,
updateInput: composerActions.setInput
}),
[
cols,
composerActions.setInput,
composerCompIdx,
composerCompletions,
composerInput,
composerInputBuf,
composerQueueEditIdx,
composerQueuedDisplay,
empty,
handleTextPaste,
pagerPageSize,
submit
]
)
const appProgress = useMemo(
() => ({
activity: turnState.activity,
reasoning: turnState.reasoning,
reasoningActive: turnState.reasoningActive,
reasoningStreaming: turnState.reasoningStreaming,
reasoningTokens: turnState.reasoningTokens,
showProgressArea,
showStreamingArea,
streaming: turnState.streaming,
subagents: turnState.subagents,
toolTokens: turnState.toolTokens,
tools: turnState.tools,
turnTrail: turnState.turnTrail
}),
[showProgressArea, showStreamingArea, turnState]
)
const appStatus = useMemo(
() => ({
cwdLabel,
sessionStartedAt: sessionStarted,
showStickyPrompt,
statusColor,
stickyPrompt,
voiceLabel
}),
[cwdLabel, sessionStarted, showStickyPrompt, statusColor, stickyPrompt, voiceLabel]
)
const appTranscript = useMemo(
() => ({
historyItems,
scrollRef,
virtualHistory,
virtualRows
}),
[historyItems, scrollRef, virtualHistory, virtualRows]
)
// ── Render ───────────────────────────────────────────────────────
return (
<GatewayProvider value={gateway}>
<AppLayout
actions={{
answerApproval,
answerClarify,
answerSecret,
answerSudo,
onModelSelect,
resumeById,
setStickyPrompt
}}
composer={{
cols,
compIdx: composerState.compIdx,
completions: composerState.completions,
empty,
handleTextPaste,
input: composerState.input,
inputBuf: composerState.inputBuf,
pagerPageSize,
queueEditIdx: composerState.queueEditIdx,
queuedDisplay: composerState.queuedDisplay,
submit,
updateInput: composerActions.setInput
}}
actions={appActions}
composer={appComposer}
mouseTracking={MOUSE_TRACKING}
progress={{
activity: turnState.activity,
reasoning: turnState.reasoning,
reasoningTokens: turnState.reasoningTokens,
reasoningActive: turnState.reasoningActive,
reasoningStreaming: turnState.reasoningStreaming,
showProgressArea,
showStreamingArea,
streaming: turnState.streaming,
toolTokens: turnState.toolTokens,
tools: turnState.tools,
turnTrail: turnState.turnTrail
}}
status={{
cwdLabel,
durationLabel,
showStickyPrompt,
statusColor,
stickyPrompt,
voiceLabel
}}
transcript={{
historyItems,
scrollRef,
virtualHistory,
virtualRows
}}
progress={appProgress}
status={appStatus}
transcript={appTranscript}
/>
</GatewayProvider>
)

View file

@ -1,14 +1,16 @@
import type { GatewayEvent } from '../gatewayClient.js'
import type { CommandsCatalogResponse, GatewayEvent, SessionResumeResponse } from '../gatewayTypes.js'
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
import {
buildToolTrailLine,
estimateTokensRough,
formatToolCall,
isToolTrailResultLine,
sameToolTrailGroup,
toolTrailLabel
} from '../lib/text.js'
import { fromSkin } from '../theme.js'
import { STREAM_BATCH_MS } from './constants.js'
import { introMsg, toTranscriptMessages } from './helpers.js'
import type { GatewayEventHandlerContext } from './interfaces.js'
import { patchOverlayState } from './overlayStore.js'
@ -19,7 +21,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
const { gw, rpc } = ctx.gateway
const { STARTUP_RESUME_ID, colsRef, newSession, resetSession, setCatalog } = ctx.session
const { bellOnComplete, stdout, sys } = ctx.system
const { appendMessage, setHistoryItems, setMessages } = ctx.transcript
const { appendMessage, setHistoryItems } = ctx.transcript
const {
clearReasoning,
@ -32,8 +34,8 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
scheduleReasoning,
scheduleStreaming,
setActivity,
setReasoningTokens,
setStreaming,
setSubagents,
setToolTokens,
setTools,
setTurnTrail
@ -53,6 +55,108 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
turnToolsRef
} = ctx.turn.refs
let pendingThinkingStatus = ''
let thinkingStatusTimer: ReturnType<typeof setTimeout> | null = null
let toolProgressTimer: ReturnType<typeof setTimeout> | null = null
const cancelThinkingStatus = () => {
pendingThinkingStatus = ''
if (thinkingStatusTimer) {
clearTimeout(thinkingStatusTimer)
thinkingStatusTimer = null
}
}
const setStatus = (status: string) => {
cancelThinkingStatus()
patchUiState({ status })
}
const scheduleThinkingStatus = (status: string) => {
pendingThinkingStatus = status
if (thinkingStatusTimer) {
return
}
thinkingStatusTimer = setTimeout(() => {
thinkingStatusTimer = null
patchUiState({ status: pendingThinkingStatus || (getUiState().busy ? 'running…' : 'ready') })
}, STREAM_BATCH_MS)
}
const scheduleToolProgress = () => {
if (toolProgressTimer) {
return
}
toolProgressTimer = setTimeout(() => {
toolProgressTimer = null
setTools([...activeToolsRef.current])
}, STREAM_BATCH_MS)
}
const upsertSubagent = (
taskIndex: number,
taskCount: number,
goal: string,
update: (current: {
durationSeconds?: number
goal: string
id: string
index: number
notes: string[]
status: 'completed' | 'failed' | 'interrupted' | 'running'
summary?: string
taskCount: number
thinking: string[]
tools: string[]
}) => {
durationSeconds?: number
goal: string
id: string
index: number
notes: string[]
status: 'completed' | 'failed' | 'interrupted' | 'running'
summary?: string
taskCount: number
thinking: string[]
tools: string[]
}
) => {
const id = `sa:${taskIndex}:${goal || 'subagent'}`
setSubagents(prev => {
const index = prev.findIndex(item => item.id === id)
const base =
index >= 0
? prev[index]!
: {
id,
index: taskIndex,
taskCount,
goal,
notes: [],
status: 'running' as const,
thinking: [],
tools: []
}
const nextItem = update(base)
if (index < 0) {
return [...prev, nextItem].sort((a, b) => a.index - b.index)
}
const next = [...prev]
next[index] = nextItem
return next
})
}
return (ev: GatewayEvent) => {
const sid = getUiState().sid
@ -60,10 +164,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
return
}
const p = ev.payload as any
switch (ev.type) {
case 'gateway.ready':
case 'gateway.ready': {
const p = ev.payload
if (p?.skin) {
patchUiState({
theme: fromSkin(
@ -75,15 +179,15 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
})
}
rpc('commands.catalog', {})
.then((r: any) => {
rpc<CommandsCatalogResponse>('commands.catalog', {})
.then(r => {
if (!r?.pairs) {
return
}
setCatalog({
canon: (r.canon ?? {}) as Record<string, string>,
categories: (r.categories ?? []) as any,
categories: r.categories ?? [],
pairs: r.pairs as [string, string][],
skillCount: (r.skill_count ?? 0) as number,
sub: (r.sub ?? {}) as Record<string, string[]>
@ -97,9 +201,9 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
if (STARTUP_RESUME_ID) {
patchUiState({ status: 'resuming…' })
gw.request('session.resume', { cols: colsRef.current, session_id: STARTUP_RESUME_ID })
.then((raw: any) => {
const r = asRpcResult(raw)
gw.request<SessionResumeResponse>('session.resume', { cols: colsRef.current, session_id: STARTUP_RESUME_ID })
.then(raw => {
const r = asRpcResult<SessionResumeResponse>(raw)
if (!r) {
throw new Error('invalid response: session.resume')
@ -114,7 +218,6 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
status: 'ready',
usage: r.info?.usage ?? getUiState().usage
})
setMessages(resumed)
setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed)
})
.catch((e: unknown) => {
@ -128,8 +231,11 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
}
break
}
case 'skin.changed': {
const p = ev.payload
case 'skin.changed':
if (p) {
patchUiState({
theme: fromSkin(p.colors ?? {}, p.branding ?? {}, p.banner_logo ?? '', p.banner_hero ?? '')
@ -137,28 +243,36 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
}
break
}
case 'session.info': {
const p = ev.payload
case 'session.info':
patchUiState(state => ({
...state,
info: p as any,
usage: p?.usage ? { ...state.usage, ...p.usage } : state.usage
info: p,
usage: p.usage ? { ...state.usage, ...p.usage } : state.usage
}))
break
}
case 'thinking.delta': {
const p = ev.payload
case 'thinking.delta':
if (p && Object.prototype.hasOwnProperty.call(p, 'text')) {
patchUiState({ status: p.text ? String(p.text) : getUiState().busy ? 'running…' : 'ready' })
scheduleThinkingStatus(p.text ? String(p.text) : getUiState().busy ? 'running…' : 'ready')
}
break
}
case 'message.start':
patchUiState({ busy: true })
endReasoningPhase()
clearReasoning()
setActivity([])
setSubagents([])
setTurnTrail([])
activeToolsRef.current = []
setTools([])
@ -168,10 +282,11 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
setToolTokens(0)
break
case 'status.update': {
const p = ev.payload
case 'status.update':
if (p?.text) {
patchUiState({ status: p.text })
setStatus(p.text)
if (p.kind && p.kind !== 'status') {
if (lastStatusNoteRef.current !== p.text) {
@ -194,8 +309,11 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
}
break
}
case 'gateway.stderr': {
const p = ev.payload
case 'gateway.stderr':
if (p?.line) {
const line = String(p.line).slice(0, 120)
const tone = /\b(error|traceback|exception|failed|spawn)\b/i.test(line) ? 'error' : 'warn'
@ -204,18 +322,24 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
}
break
}
case 'gateway.start_timeout':
patchUiState({ status: 'gateway startup timeout' })
case 'gateway.start_timeout': {
const p = ev.payload
setStatus('gateway startup timeout')
pushActivity(
`gateway startup timed out${p?.python || p?.cwd ? ` · ${String(p?.python || '')} ${String(p?.cwd || '')}`.trim() : ''} · /logs to inspect`,
'error'
)
break
}
case 'gateway.protocol_error':
patchUiState({ status: 'protocol warning' })
case 'gateway.protocol_error': {
const p = ev.payload
setStatus('protocol warning')
if (statusTimerRef.current) {
clearTimeout(statusTimerRef.current)
@ -236,17 +360,22 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
}
break
}
case 'reasoning.delta': {
const p = ev.payload
case 'reasoning.delta':
if (p?.text) {
reasoningRef.current += p.text
setReasoningTokens(estimateTokensRough(reasoningRef.current))
scheduleReasoning()
pulseReasoningStreaming()
}
break
}
case 'reasoning.available': {
const p = ev.payload
const incoming = String(p?.text ?? '').trim()
if (!incoming) {
@ -261,7 +390,6 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
// nothing.
if (!current) {
reasoningRef.current = incoming
setReasoningTokens(estimateTokensRough(reasoningRef.current))
scheduleReasoning()
pulseReasoningStreaming()
}
@ -269,7 +397,9 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
break
}
case 'tool.progress':
case 'tool.progress': {
const p = ev.payload
if (p?.preview) {
const index = activeToolsRef.current.findIndex(tool => tool.name === p.name)
@ -278,28 +408,35 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
next[index] = { ...next[index]!, context: p.preview as string }
activeToolsRef.current = next
setTools(next)
scheduleToolProgress()
}
}
break
}
case 'tool.generating': {
const p = ev.payload
case 'tool.generating':
if (p?.name) {
pushTrail(`drafting ${p.name}`)
}
break
}
case 'tool.start': {
const p = ev.payload
pruneTransient()
endReasoningPhase()
const ctx = (p.context as string) || ''
const name = p.name ?? 'tool'
const ctx = p.context ?? ''
const sample = `${String(p.name ?? '')} ${ctx}`.trim()
toolTokenAccRef.current += sample ? estimateTokensRough(sample) : 0
setToolTokens(toolTokenAccRef.current)
activeToolsRef.current = [
...activeToolsRef.current,
{ id: p.tool_id, name: p.name, context: ctx, startedAt: Date.now() }
{ id: p.tool_id, name, context: ctx, startedAt: Date.now() }
]
setTools(activeToolsRef.current)
@ -307,17 +444,13 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
}
case 'tool.complete': {
const p = ev.payload
toolCompleteRibbonRef.current = null
const done = activeToolsRef.current.find(tool => tool.id === p.tool_id)
const name = done?.name ?? p.name
const name = done?.name ?? p.name ?? 'tool'
const label = toolTrailLabel(name)
const line = buildToolTrailLine(
name,
done?.context || '',
!!p.error,
(p.error as string) || (p.summary as string) || ''
)
const line = buildToolTrailLine(name, done?.context || '', !!p.error, p.error || p.summary || '')
const next = [...turnToolsRef.current.filter(item => !sameToolTrailGroup(label, item)), line]
@ -333,37 +466,46 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
setTurnTrail(turnToolsRef.current)
if (p?.inline_diff) {
sys(p.inline_diff as string)
sys(p.inline_diff)
}
break
}
case 'clarify.request':
case 'clarify.request': {
const p = ev.payload
patchOverlayState({ clarify: { choices: p.choices, question: p.question, requestId: p.request_id } })
patchUiState({ status: 'waiting for input…' })
setStatus('waiting for input…')
break
}
case 'approval.request':
case 'approval.request': {
const p = ev.payload
patchOverlayState({ approval: { command: p.command, description: p.description } })
patchUiState({ status: 'approval needed' })
setStatus('approval needed')
break
}
case 'sudo.request':
case 'sudo.request': {
const p = ev.payload
patchOverlayState({ sudo: { requestId: p.request_id } })
patchUiState({ status: 'sudo password needed' })
setStatus('sudo password needed')
break
}
case 'secret.request':
case 'secret.request': {
const p = ev.payload
patchOverlayState({ secret: { envVar: p.env_var, prompt: p.prompt, requestId: p.request_id } })
patchUiState({ status: 'secret input needed' })
setStatus('secret input needed')
break
}
case 'background.complete':
case 'background.complete': {
const p = ev.payload
patchUiState(state => {
const next = new Set(state.bgTasks)
@ -374,8 +516,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
sys(`[bg ${p.task_id}] ${p.text}`)
break
}
case 'btw.complete':
case 'btw.complete': {
const p = ev.payload
patchUiState(state => {
const next = new Set(state.bgTasks)
@ -386,8 +530,92 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
sys(`[btw] ${p.text}`)
break
}
case 'message.delta':
case 'subagent.start': {
const p = ev.payload
upsertSubagent(p.task_index, p.task_count ?? 1, p.goal, current => ({
...current,
goal: p.goal || current.goal,
status: 'running',
taskCount: p.task_count ?? current.taskCount
}))
break
}
case 'subagent.thinking': {
const p = ev.payload
const text = String(p.text ?? '').trim()
if (!text) {
break
}
upsertSubagent(p.task_index, p.task_count ?? 1, p.goal, current => ({
...current,
goal: p.goal || current.goal,
status: current.status === 'completed' ? current.status : 'running',
taskCount: p.task_count ?? current.taskCount,
thinking: current.thinking.at(-1) === text ? current.thinking : [...current.thinking, text].slice(-6)
}))
break
}
case 'subagent.tool': {
const p = ev.payload
const line = formatToolCall(p.tool_name ?? 'delegate_task', p.tool_preview ?? p.text ?? '')
upsertSubagent(p.task_index, p.task_count ?? 1, p.goal, current => ({
...current,
goal: p.goal || current.goal,
status: current.status === 'completed' ? current.status : 'running',
taskCount: p.task_count ?? current.taskCount,
tools: current.tools.at(-1) === line ? current.tools : [...current.tools, line].slice(-8)
}))
break
}
case 'subagent.progress': {
const p = ev.payload
const text = String(p.text ?? '').trim()
if (!text) {
break
}
upsertSubagent(p.task_index, p.task_count ?? 1, p.goal, current => ({
...current,
goal: p.goal || current.goal,
status: current.status === 'completed' ? current.status : 'running',
taskCount: p.task_count ?? current.taskCount,
notes: current.notes.at(-1) === text ? current.notes : [...current.notes, text].slice(-6)
}))
break
}
case 'subagent.complete': {
const p = ev.payload
const status = p.status ?? 'completed'
upsertSubagent(p.task_index, p.task_count ?? 1, p.goal, current => ({
...current,
durationSeconds: p.duration_seconds ?? current.durationSeconds,
goal: p.goal || current.goal,
status,
summary: p.summary || p.text || current.summary,
taskCount: p.task_count ?? current.taskCount
}))
break
}
case 'message.delta': {
const p = ev.payload
pruneTransient()
endReasoningPhase()
@ -397,7 +625,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
}
break
}
case 'message.complete': {
const p = ev.payload
const finalText = (p?.rendered ?? p?.text ?? bufRef.current).trimStart()
const persisted = persistedToolLabelsRef.current
const savedReasoning = reasoningRef.current.trim()
@ -432,7 +663,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
persistedToolLabelsRef.current.clear()
setActivity([])
bufRef.current = ''
patchUiState({ status: 'ready' })
setStatus('ready')
if (p?.usage) {
patchUiState({ usage: p.usage })
@ -451,7 +682,8 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
break
}
case 'error':
case 'error': {
const p = ev.payload
idle()
clearReasoning()
turnToolsRef.current = []
@ -464,9 +696,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
pushActivity(String(p?.message || 'unknown error'), 'error')
sys(`error: ${p?.message}`)
patchUiState({ status: 'ready' })
setStatus('ready')
break
}
}
}
}

View file

@ -1,4 +1,12 @@
import { HOTKEYS } from '../constants.js'
import type {
BackgroundStartResponse,
SessionHistoryResponse,
SlashExecResponse,
ToolsConfigureResponse,
ToolsListResponse,
ToolsShowResponse
} from '../gatewayTypes.js'
import { writeOsc52Clipboard } from '../lib/osc52.js'
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
import { fmtK } from '../lib/text.js'
@ -12,7 +20,7 @@ import { getUiState, patchUiState } from './uiStore.js'
export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => boolean {
const { enqueue, hasSelection, paste, queueRef, selection, setInput } = ctx.composer
const { gw, rpc } = ctx.gateway
const { catalog, lastUserMsg, maybeWarn, messages } = ctx.local
const { catalog, getHistoryItems, getLastUserMsg, maybeWarn } = ctx.local
const {
closeSession,
@ -24,9 +32,24 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
setSessionStartedAt
} = ctx.session
const { page, panel, send, setHistoryItems, setMessages, sys, trimLastExchange } = ctx.transcript
const { page, panel, send, setHistoryItems, sys, trimLastExchange } = ctx.transcript
const { setVoiceEnabled } = ctx.voice
const showSlashOutput = (title: string, command: string) => {
gw.request<SlashExecResponse>('slash.exec', { command, session_id: getUiState().sid })
.then(r => {
const text = r?.warning ? `warning: ${r.warning}\n${r?.output || '(no output)'}` : r?.output || '(no output)'
const lines = text.split('\n').filter(Boolean)
if (lines.length > 2 || text.length > 180) {
page(text, title)
} else {
sys(text)
}
})
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
}
const handler = (cmd: string): boolean => {
const ui = getUiState()
const detailsMode = ui.detailsMode
@ -160,7 +183,7 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
}
}
const all = messages.filter((m: any) => m.role === 'assistant')
const all = getHistoryItems().filter((m: any) => m.role === 'assistant')
if (arg && Number.isNaN(parseInt(arg, 10))) {
sys('usage: /copy [number]')
@ -244,7 +267,6 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
}
if (r.removed > 0) {
setMessages((prev: any[]) => trimLastExchange(prev))
setHistoryItems((prev: any[]) => trimLastExchange(prev))
sys(`undid ${r.removed} messages`)
} else {
@ -253,8 +275,9 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
})
return true
case 'retry': {
const lastUserMsg = getLastUserMsg()
case 'retry':
if (!lastUserMsg) {
sys('nothing to retry')
@ -273,7 +296,6 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
return
}
setMessages((prev: any[]) => trimLastExchange(prev))
setHistoryItems((prev: any[]) => trimLastExchange(prev))
send(lastUserMsg)
})
@ -284,6 +306,7 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
send(lastUserMsg)
return true
}
case 'background':
@ -294,13 +317,15 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
return true
}
rpc('prompt.background', { session_id: sid, text: arg }).then((r: any) => {
if (!r?.task_id) {
rpc<BackgroundStartResponse>('prompt.background', { session_id: sid, text: arg }).then(r => {
const taskId = r?.task_id
if (!taskId) {
return
}
patchUiState(state => ({ ...state, bgTasks: new Set(state.bgTasks).add(r.task_id) }))
sys(`bg ${r.task_id} started`)
patchUiState(state => ({ ...state, bgTasks: new Set(state.bgTasks).add(taskId) }))
sys(`bg ${taskId} started`)
})
return true
@ -483,7 +508,6 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
if (Array.isArray(r.messages)) {
const resumed = toTranscriptMessages(r.messages)
setMessages(resumed)
setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed)
}
@ -526,7 +550,6 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
patchUiState({ sid: r.session_id })
setSessionStartedAt(Date.now())
setHistoryItems([])
setMessages([])
sys(`branched → ${r.title}`)
}
})
@ -547,6 +570,26 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
return true
case 'fast':
showSlashOutput('Fast', cmd.slice(1))
return true
case 'debug':
showSlashOutput('Debug', cmd.slice(1))
return true
case 'snapshot':
showSlashOutput('Snapshot', cmd.slice(1))
return true
case 'platforms':
showSlashOutput('Platforms', cmd.slice(1))
return true
case 'title':
rpc('session.title', { session_id: sid, ...(arg ? { title: arg } : {}) }).then((r: any) => {
if (!r) {
@ -618,12 +661,28 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
return true
case 'history':
rpc('session.history', { session_id: sid }).then((r: any) => {
rpc<SessionHistoryResponse>('session.history', { session_id: sid }).then(r => {
if (typeof r?.count !== 'number') {
return
}
sys(`${r.count} messages`)
if (!r.messages?.length) {
sys(`${r.count} messages`)
return
}
const text = r.messages
.map((msg, index) => {
if (msg.role === 'tool') {
return `[Tool #${index + 1}] ${msg.name || 'tool'} ${msg.context || ''}`.trim()
}
return `[${msg.role === 'assistant' ? 'Hermes' : msg.role === 'user' ? 'You' : 'System'} #${index + 1}] ${msg.text || ''}`.trim()
})
.join('\n\n')
page(text, `History (${r.count})`)
})
return true
@ -917,29 +976,98 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
return true
case 'tools': {
const [subcommand, ...names] = arg.trim().split(/\s+/).filter(Boolean)
case 'tools':
rpc('tools.list', { session_id: sid })
.then((r: any) => {
if (!r) {
return
}
if (!subcommand) {
rpc<ToolsShowResponse>('tools.show', { session_id: sid })
.then(r => {
if (!r?.sections?.length) {
return sys('no tools')
}
if (!r.toolsets?.length) {
return sys('no tools')
}
panel(
`Tools${typeof r.total === 'number' ? ` (${r.total})` : ''}`,
r.sections.map(section => ({
title: section.name,
rows: section.tools.map(tool => [tool.name, tool.description] as [string, string])
}))
)
})
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
panel(
'Tools',
r.toolsets.map((ts: any) => ({
title: `${ts.enabled ? '*' : ' '} ${ts.name} [${ts.tool_count} tools]`,
items: ts.tools
}))
)
return true
}
if (subcommand === 'list') {
rpc<ToolsListResponse>('tools.list', { session_id: sid })
.then(r => {
if (!r?.toolsets?.length) {
return sys('no tools')
}
panel(
'Tools',
r.toolsets.map(ts => ({
title: `${ts.enabled ? '*' : ' '} ${ts.name} [${ts.tool_count} tools]`,
items: ts.tools
}))
)
})
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
return true
}
if (subcommand === 'disable' || subcommand === 'enable') {
if (!names.length) {
sys(`usage: /tools ${subcommand} <name> [name ...]`)
sys(`built-in toolset: /tools ${subcommand} web`)
sys(`MCP tool: /tools ${subcommand} github:create_issue`)
return true
}
rpc<ToolsConfigureResponse>('tools.configure', {
action: subcommand,
names,
session_id: sid
})
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
.then(r => {
if (!r) {
return
}
if (r.info) {
setSessionStartedAt(Date.now())
resetVisibleHistory(r.info)
}
if (r.changed?.length) {
sys(`${subcommand === 'disable' ? 'disabled' : 'enabled'}: ${r.changed.join(', ')}`)
}
if (r.unknown?.length) {
sys(`unknown toolsets: ${r.unknown.join(', ')}`)
}
if (r.missing_servers?.length) {
sys(`missing MCP servers: ${r.missing_servers.join(', ')}`)
}
if (r.reset) {
sys('session reset. new tool configuration is active.')
}
})
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
return true
}
sys('usage: /tools [list|disable|enable] ...')
return true
}
case 'toolsets':
rpc('toolsets.list', { session_id: sid })
@ -969,6 +1097,28 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
return true
default:
if (catalog?.canon) {
const needle = `/${name}`.toLowerCase()
const matches = [
...new Set(
Object.entries(catalog.canon)
.filter(([alias]) => alias.startsWith(needle))
.map(([, canon]) => canon)
)
]
if (matches.length === 1 && matches[0]!.toLowerCase() !== needle) {
return handler(`${matches[0]}${arg ? ' ' + arg : ''}`)
}
if (matches.length > 1) {
sys(`ambiguous command: ${matches.slice(0, 6).join(', ')}${matches.length > 6 ? ', …' : ''}`)
return true
}
}
gw.request('slash.exec', { command: cmd.slice(1), session_id: sid })
.then((r: any) => {
sys(

View file

@ -16,6 +16,7 @@ import type {
SecretReq,
SessionInfo,
SlashCatalog,
SubagentProgress,
SudoReq,
Usage
} from '../types.js'
@ -35,7 +36,7 @@ export interface CompletionItem {
}
export interface GatewayRpc {
(method: string, params?: Record<string, unknown>): Promise<RpcResult | null>
<T extends RpcResult = RpcResult>(method: string, params?: Record<string, unknown>): Promise<T | null>
}
export interface GatewayServices {
@ -176,6 +177,7 @@ export interface TurnActions {
setToolTokens: StateSetter<number>
setReasoningStreaming: StateSetter<boolean>
setStreaming: StateSetter<string>
setSubagents: StateSetter<SubagentProgress[]>
setTools: StateSetter<ActiveTool[]>
setTurnTrail: StateSetter<string[]>
}
@ -204,6 +206,7 @@ export interface TurnState {
reasoningActive: boolean
reasoningStreaming: boolean
streaming: string
subagents: SubagentProgress[]
toolTokens: number
tools: ActiveTool[]
turnTrail: string[]
@ -278,7 +281,6 @@ export interface GatewayEventHandlerContext {
transcript: {
appendMessage: (msg: Msg) => void
setHistoryItems: StateSetter<Msg[]>
setMessages: StateSetter<Msg[]>
}
turn: {
actions: Pick<
@ -295,6 +297,7 @@ export interface GatewayEventHandlerContext {
| 'setActivity'
| 'setReasoningTokens'
| 'setStreaming'
| 'setSubagents'
| 'setToolTokens'
| 'setTools'
| 'setTurnTrail'
@ -328,9 +331,9 @@ export interface SlashHandlerContext {
gateway: GatewayServices
local: {
catalog: SlashCatalog | null
lastUserMsg: string
getHistoryItems: () => Msg[]
getLastUserMsg: () => string
maybeWarn: (value: any) => void
messages: Msg[]
}
session: {
closeSession: (targetSid?: string | null) => Promise<unknown>
@ -346,7 +349,6 @@ export interface SlashHandlerContext {
panel: (title: string, sections: PanelSection[]) => void
send: (text: string) => void
setHistoryItems: StateSetter<Msg[]>
setMessages: StateSetter<Msg[]>
sys: (text: string) => void
trimLastExchange: (items: Msg[]) => Msg[]
}
@ -389,6 +391,7 @@ export interface AppLayoutProgressProps {
showProgressArea: boolean
showStreamingArea: boolean
streaming: string
subagents: SubagentProgress[]
toolTokens: number
tools: ActiveTool[]
turnTrail: string[]
@ -396,7 +399,7 @@ export interface AppLayoutProgressProps {
export interface AppLayoutStatusProps {
cwdLabel: string
durationLabel: string
sessionStartedAt: number | null
showStickyPrompt: boolean
statusColor: string
stickyPrompt: string

View file

@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { isTransientTrailLine, sameToolTrailGroup } from '../lib/text.js'
import type { ActiveTool, ActivityItem } from '../types.js'
import { estimateTokensRough, isTransientTrailLine, sameToolTrailGroup } from '../lib/text.js'
import type { ActiveTool, ActivityItem, SubagentProgress } from '../types.js'
import { REASONING_PULSE_MS, STREAM_BATCH_MS } from './constants.js'
import type { InterruptTurnOptions, ToolCompleteRibbon, UseTurnStateResult } from './interfaces.js'
@ -16,6 +16,7 @@ export function useTurnState(): UseTurnStateResult {
const [toolTokens, setToolTokens] = useState(0)
const [reasoningStreaming, setReasoningStreaming] = useState(false)
const [streaming, setStreaming] = useState('')
const [subagents, setSubagents] = useState<SubagentProgress[]>([])
const [tools, setTools] = useState<ActiveTool[]>([])
const [turnTrail, setTurnTrail] = useState<string[]>([])
@ -73,6 +74,7 @@ export function useTurnState(): UseTurnStateResult {
reasoningTimerRef.current = setTimeout(() => {
reasoningTimerRef.current = null
setReasoning(reasoningRef.current)
setReasoningTokens(estimateTokensRough(reasoningRef.current))
}, STREAM_BATCH_MS)
}, [])
@ -147,6 +149,7 @@ export function useTurnState(): UseTurnStateResult {
const idle = useCallback(() => {
endReasoningPhase()
activeToolsRef.current = []
setSubagents([])
setTools([])
setTurnTrail([])
patchUiState({ busy: false })
@ -165,7 +168,7 @@ export function useTurnState(): UseTurnStateResult {
({ appendMessage, gw, sid, sys }: InterruptTurnOptions) => {
interruptedRef.current = true
gw.request('session.interrupt', { session_id: sid }).catch(() => {})
const partial = (streaming || bufRef.current).trimStart()
const partial = bufRef.current.trimStart()
if (partial) {
appendMessage({ role: 'assistant', text: partial + '\n\n*[interrupted]*' })
@ -188,7 +191,7 @@ export function useTurnState(): UseTurnStateResult {
patchUiState({ status: 'ready' })
}, 1500)
},
[clearReasoning, idle, streaming]
[clearReasoning, idle]
)
const actions = useMemo(
@ -210,6 +213,7 @@ export function useTurnState(): UseTurnStateResult {
setToolTokens,
setReasoningStreaming,
setStreaming,
setSubagents,
setTools,
setTurnTrail
}),
@ -256,10 +260,22 @@ export function useTurnState(): UseTurnStateResult {
toolTokens,
reasoningStreaming,
streaming,
subagents,
tools,
turnTrail
}),
[activity, reasoning, reasoningTokens, reasoningActive, toolTokens, reasoningStreaming, streaming, tools, turnTrail]
[
activity,
reasoning,
reasoningTokens,
reasoningActive,
toolTokens,
reasoningStreaming,
streaming,
subagents,
tools,
turnTrail
]
)
return {

View file

@ -0,0 +1,40 @@
import { atom } from 'nanostores'
import { WIDGET_CATALOG } from '../widgets.js'
export interface WidgetState {
enabled: Record<string, boolean>
params: Record<string, Record<string, string>>
}
function defaults(): WidgetState {
const enabled: Record<string, boolean> = {}
for (const w of WIDGET_CATALOG) {
enabled[w.id] = w.defaultOn
}
return { enabled, params: {} }
}
export const $widgetState = atom<WidgetState>(defaults())
export function toggleWidget(id: string, force?: boolean) {
const s = $widgetState.get()
const next = force ?? !s.enabled[id]
$widgetState.set({ ...s, enabled: { ...s.enabled, [id]: next } })
return next
}
export function setWidgetParam(id: string, key: string, value: string) {
const s = $widgetState.get()
const prev = s.params[id] ?? {}
$widgetState.set({ ...s, params: { ...s.params, [id]: { ...prev, [key]: value } } })
}
export function getWidgetEnabled(id: string): boolean {
return $widgetState.get().enabled[id] ?? false
}

View file

@ -1,7 +1,7 @@
import { Box, type ScrollBoxHandle, Text } from '@hermes/ink'
import { type ReactNode, type RefObject, useCallback, useEffect, useState, useSyncExternalStore } from 'react'
import { stickyPromptFromViewport } from '../app/helpers.js'
import { fmtDuration, stickyPromptFromViewport } from '../app/helpers.js'
import { fmtK } from '../lib/text.js'
import type { Theme } from '../theme.js'
import type { Msg, Usage } from '../types.js'
@ -33,6 +33,19 @@ function ctxBar(pct: number | undefined, w = 10) {
return '█'.repeat(filled) + '░'.repeat(w - filled)
}
function SessionDuration({ startedAt }: { startedAt: number }) {
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
setNow(Date.now())
const id = setInterval(() => setNow(Date.now()), 1000)
return () => clearInterval(id)
}, [startedAt])
return fmtDuration(now - startedAt)
}
export function StatusRule({
cwdLabel,
cols,
@ -41,7 +54,7 @@ export function StatusRule({
model,
usage,
bgCount,
durationLabel,
sessionStartedAt,
voiceLabel,
t
}: {
@ -52,7 +65,7 @@ export function StatusRule({
model: string
usage: Usage
bgCount: number
durationLabel?: string
sessionStartedAt?: number | null
voiceLabel?: string
t: Theme
}) {
@ -83,7 +96,12 @@ export function StatusRule({
<Text color={barColor}>[{bar}]</Text> <Text color={barColor}>{pctLabel}</Text>
</Text>
) : null}
{durationLabel ? <Text color={t.color.dim}> {durationLabel}</Text> : null}
{sessionStartedAt ? (
<Text color={t.color.dim}>
{' │ '}
<SessionDuration startedAt={sessionStartedAt} />
</Text>
) : null}
{voiceLabel ? <Text color={t.color.dim}> {voiceLabel}</Text> : null}
{bgCount > 0 ? <Text color={t.color.dim}> {bgCount} bg</Text> : null}
</Text>
@ -177,6 +195,8 @@ export function TranscriptScrollbar({ scrollRef, t }: { scrollRef: RefObject<Scr
const travel = Math.max(1, vp - thumb)
const pos = Math.max(0, (s?.getScrollTop() ?? 0) + (s?.getPendingDelta() ?? 0))
const thumbTop = scrollable ? Math.round((pos / Math.max(1, total - vp)) * travel) : 0
const thumbColor = grab !== null ? t.color.gold : hover ? t.color.amber : t.color.bronze
const trackColor = hover ? t.color.bronze : t.color.dim
const jump = (row: number, offset: number) => {
if (!s || !scrollable) {
@ -203,25 +223,27 @@ export function TranscriptScrollbar({ scrollRef, t }: { scrollRef: RefObject<Scr
onMouseUp={() => setGrab(null)}
width={1}
>
{Array.from({ length: vp }, (_, i) => {
const active = i >= thumbTop && i < thumbTop + thumb
const color = active
? grab !== null
? t.color.gold
: hover
? t.color.amber
: t.color.bronze
: hover
? t.color.bronze
: t.color.dim
return (
<Text color={color} dimColor={!active && !hover} key={i}>
{scrollable ? (active ? '┃' : '│') : ' '}
</Text>
)
})}
{!scrollable ? (
<Text color={trackColor} dimColor>
{' \n'.repeat(Math.max(0, vp - 1))}{' '}
</Text>
) : (
<>
{thumbTop > 0 ? (
<Text color={trackColor} dimColor={!hover}>
{`${'│\n'.repeat(Math.max(0, thumbTop - 1))}${thumbTop > 0 ? '│' : ''}`}
</Text>
) : null}
{thumb > 0 ? (
<Text color={thumbColor}>{`${'┃\n'.repeat(Math.max(0, thumb - 1))}${thumb > 0 ? '┃' : ''}`}</Text>
) : null}
{vp - thumbTop - thumb > 0 ? (
<Text color={trackColor} dimColor={!hover}>
{`${'│\n'.repeat(Math.max(0, vp - thumbTop - thumb - 1))}${vp - thumbTop - thumb > 0 ? '│' : ''}`}
</Text>
) : null}
</>
)}
</Box>
)
}

View file

@ -1,5 +1,6 @@
import { AlternateScreen, Box, NoSelect, ScrollBox, Text } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import { memo } from 'react'
import { PLACEHOLDER } from '../app/constants.js'
import type { AppLayoutProps } from '../app/interfaces.js'
@ -14,172 +15,203 @@ import { QueuedMessages } from './queuedMessages.js'
import { TextInput } from './textInput.js'
import { ToolTrail } from './thinking.js'
export function AppLayout({ actions, composer, mouseTracking, progress, status, transcript }: AppLayoutProps) {
const TranscriptPane = memo(function TranscriptPane({
actions,
composer,
progress,
transcript
}: Pick<AppLayoutProps, 'actions' | 'composer' | 'progress' | 'transcript'>) {
const ui = useStore($uiState)
const isBlocked = useStore($isBlocked)
const visibleHistory = transcript.virtualRows.slice(transcript.virtualHistory.start, transcript.virtualHistory.end)
return (
<AlternateScreen mouseTracking={mouseTracking}>
<Box flexDirection="column" flexGrow={1}>
<Box flexDirection="row" flexGrow={1}>
<ScrollBox flexDirection="column" flexGrow={1} flexShrink={1} ref={transcript.scrollRef} stickyScroll>
<Box flexDirection="column" paddingX={1}>
{transcript.virtualHistory.topSpacer > 0 ? <Box height={transcript.virtualHistory.topSpacer} /> : null}
<>
<ScrollBox flexDirection="column" flexGrow={1} flexShrink={1} ref={transcript.scrollRef} stickyScroll>
<Box flexDirection="column" paddingX={1}>
{transcript.virtualHistory.topSpacer > 0 ? <Box height={transcript.virtualHistory.topSpacer} /> : null}
{visibleHistory.map(row => (
<Box flexDirection="column" key={row.key} ref={transcript.virtualHistory.measureRef(row.key)}>
{row.msg.kind === 'intro' && row.msg.info ? (
<Box flexDirection="column" paddingTop={1}>
<Banner t={ui.theme} />
<SessionPanel info={row.msg.info} sid={ui.sid} t={ui.theme} />
</Box>
) : row.msg.kind === 'panel' && row.msg.panelData ? (
<Panel sections={row.msg.panelData.sections} t={ui.theme} title={row.msg.panelData.title} />
) : (
<MessageLine
cols={composer.cols}
compact={ui.compact}
detailsMode={ui.detailsMode}
msg={row.msg}
t={ui.theme}
/>
)}
{visibleHistory.map(row => (
<Box flexDirection="column" key={row.key} ref={transcript.virtualHistory.measureRef(row.key)}>
{row.msg.kind === 'intro' && row.msg.info ? (
<Box flexDirection="column" paddingTop={1}>
<Banner t={ui.theme} />
<SessionPanel info={row.msg.info} sid={ui.sid} t={ui.theme} />
</Box>
))}
{transcript.virtualHistory.bottomSpacer > 0 ? (
<Box height={transcript.virtualHistory.bottomSpacer} />
) : null}
{progress.showProgressArea && (
<ToolTrail
activity={progress.activity}
busy={ui.busy && !progress.streaming}
detailsMode={ui.detailsMode}
reasoning={progress.reasoning}
reasoningActive={progress.reasoningActive}
reasoningStreaming={progress.reasoningStreaming}
reasoningTokens={progress.reasoningTokens}
t={ui.theme}
tools={progress.tools}
toolTokens={progress.toolTokens}
trail={progress.turnTrail}
/>
)}
{progress.showStreamingArea && (
) : row.msg.kind === 'panel' && row.msg.panelData ? (
<Panel sections={row.msg.panelData.sections} t={ui.theme} title={row.msg.panelData.title} />
) : (
<MessageLine
cols={composer.cols}
compact={ui.compact}
detailsMode={ui.detailsMode}
isStreaming
msg={{ role: 'assistant', text: progress.streaming }}
msg={row.msg}
t={ui.theme}
/>
)}
</Box>
</ScrollBox>
))}
<NoSelect flexShrink={0} marginLeft={1}>
<TranscriptScrollbar scrollRef={transcript.scrollRef} t={ui.theme} />
</NoSelect>
{transcript.virtualHistory.bottomSpacer > 0 ? <Box height={transcript.virtualHistory.bottomSpacer} /> : null}
<StickyPromptTracker
messages={transcript.historyItems}
offsets={transcript.virtualHistory.offsets}
onChange={actions.setStickyPrompt}
scrollRef={transcript.scrollRef}
/>
</Box>
<NoSelect flexDirection="column" flexShrink={0} fromLeftEdge paddingX={1}>
<QueuedMessages
cols={composer.cols}
queued={composer.queuedDisplay}
queueEditIdx={composer.queueEditIdx}
t={ui.theme}
/>
{ui.bgTasks.size > 0 && (
<Text color={ui.theme.color.dim as any}>
{ui.bgTasks.size} background {ui.bgTasks.size === 1 ? 'task' : 'tasks'} running
</Text>
{progress.showProgressArea && (
<ToolTrail
activity={progress.activity}
busy={ui.busy && !progress.streaming}
detailsMode={ui.detailsMode}
reasoning={progress.reasoning}
reasoningActive={progress.reasoningActive}
reasoningStreaming={progress.reasoningStreaming}
reasoningTokens={progress.reasoningTokens}
subagents={progress.subagents}
t={ui.theme}
tools={progress.tools}
toolTokens={progress.toolTokens}
trail={progress.turnTrail}
/>
)}
{status.showStickyPrompt ? (
<Text color={ui.theme.color.dim as any} wrap="truncate-end">
<Text color={ui.theme.color.label as any}> </Text>
{status.stickyPrompt}
</Text>
) : (
<Text> </Text>
)}
<Box flexDirection="column" position="relative">
{ui.statusBar && (
<StatusRule
bgCount={ui.bgTasks.size}
cols={composer.cols}
cwdLabel={status.cwdLabel}
durationLabel={status.durationLabel}
model={ui.info?.model?.split('/').pop() ?? ''}
status={ui.status}
statusColor={status.statusColor}
t={ui.theme}
usage={ui.usage}
voiceLabel={status.voiceLabel}
/>
)}
<AppOverlays
{progress.showStreamingArea && (
<MessageLine
cols={composer.cols}
compIdx={composer.compIdx}
completions={composer.completions}
onApprovalChoice={actions.answerApproval}
onClarifyAnswer={actions.answerClarify}
onModelSelect={actions.onModelSelect}
onPickerSelect={actions.resumeById}
onSecretSubmit={actions.answerSecret}
onSudoSubmit={actions.answerSudo}
pagerPageSize={composer.pagerPageSize}
compact={ui.compact}
detailsMode={ui.detailsMode}
isStreaming
msg={{ role: 'assistant', text: progress.streaming }}
t={ui.theme}
/>
)}
</Box>
</ScrollBox>
<NoSelect flexShrink={0} marginLeft={1}>
<TranscriptScrollbar scrollRef={transcript.scrollRef} t={ui.theme} />
</NoSelect>
<StickyPromptTracker
messages={transcript.historyItems}
offsets={transcript.virtualHistory.offsets}
onChange={actions.setStickyPrompt}
scrollRef={transcript.scrollRef}
/>
</>
)
})
const ComposerPane = memo(function ComposerPane({
actions,
composer,
status
}: Pick<AppLayoutProps, 'actions' | 'composer' | 'status'>) {
const ui = useStore($uiState)
const isBlocked = useStore($isBlocked)
return (
<NoSelect flexDirection="column" flexShrink={0} fromLeftEdge paddingX={1}>
<QueuedMessages
cols={composer.cols}
queued={composer.queuedDisplay}
queueEditIdx={composer.queueEditIdx}
t={ui.theme}
/>
{ui.bgTasks.size > 0 && (
<Text color={ui.theme.color.dim as any}>
{ui.bgTasks.size} background {ui.bgTasks.size === 1 ? 'task' : 'tasks'} running
</Text>
)}
{status.showStickyPrompt ? (
<Text color={ui.theme.color.dim as any} wrap="truncate-end">
<Text color={ui.theme.color.label as any}> </Text>
{status.stickyPrompt}
</Text>
) : (
<Text> </Text>
)}
<Box flexDirection="column" position="relative">
{ui.statusBar && (
<StatusRule
bgCount={ui.bgTasks.size}
cols={composer.cols}
cwdLabel={status.cwdLabel}
model={ui.info?.model?.split('/').pop() ?? ''}
sessionStartedAt={status.sessionStartedAt}
status={ui.status}
statusColor={status.statusColor}
t={ui.theme}
usage={ui.usage}
voiceLabel={status.voiceLabel}
/>
)}
<AppOverlays
cols={composer.cols}
compIdx={composer.compIdx}
completions={composer.completions}
onApprovalChoice={actions.answerApproval}
onClarifyAnswer={actions.answerClarify}
onModelSelect={actions.onModelSelect}
onPickerSelect={actions.resumeById}
onSecretSubmit={actions.answerSecret}
onSudoSubmit={actions.answerSudo}
pagerPageSize={composer.pagerPageSize}
/>
</Box>
{!isBlocked && (
<Box flexDirection="column" marginBottom={1}>
{composer.inputBuf.map((line, i) => (
<Box key={i}>
<Box width={3}>
<Text color={ui.theme.color.dim as any}>{i === 0 ? `${ui.theme.brand.prompt} ` : ' '}</Text>
</Box>
<Text color={ui.theme.color.cornsilk as any}>{line || ' '}</Text>
</Box>
))}
<Box>
<Box width={3}>
<Text bold color={ui.theme.color.gold as any}>
{composer.inputBuf.length ? ' ' : `${ui.theme.brand.prompt} `}
</Text>
</Box>
<TextInput
columns={Math.max(20, composer.cols - 3)}
onChange={composer.updateInput}
onPaste={composer.handleTextPaste}
onSubmit={composer.submit}
placeholder={composer.empty ? PLACEHOLDER : ui.busy ? 'Ctrl+C to interrupt…' : ''}
value={composer.input}
/>
</Box>
</Box>
)}
{!isBlocked && (
<Box flexDirection="column" marginBottom={1}>
{composer.inputBuf.map((line, i) => (
<Box key={i}>
<Box width={3}>
<Text color={ui.theme.color.dim as any}>{i === 0 ? `${ui.theme.brand.prompt} ` : ' '}</Text>
</Box>
{!composer.empty && !ui.sid && <Text color={ui.theme.color.dim as any}> {ui.status}</Text>}
</NoSelect>
)
})
<Text color={ui.theme.color.cornsilk as any}>{line || ' '}</Text>
</Box>
))}
export const AppLayout = memo(function AppLayout({
actions,
composer,
mouseTracking,
progress,
status,
transcript
}: AppLayoutProps) {
return (
<AlternateScreen mouseTracking={mouseTracking}>
<Box flexDirection="column" flexGrow={1}>
<Box flexDirection="row" flexGrow={1}>
<TranscriptPane actions={actions} composer={composer} progress={progress} transcript={transcript} />
</Box>
<Box>
<Box width={3}>
<Text bold color={ui.theme.color.gold as any}>
{composer.inputBuf.length ? ' ' : `${ui.theme.brand.prompt} `}
</Text>
</Box>
<TextInput
columns={Math.max(20, composer.cols - 3)}
onChange={composer.updateInput}
onPaste={composer.handleTextPaste}
onSubmit={composer.submit}
placeholder={composer.empty ? PLACEHOLDER : ui.busy ? 'Ctrl+C to interrupt…' : ''}
value={composer.input}
/>
</Box>
</Box>
)}
{!composer.empty && !ui.sid && <Text color={ui.theme.color.dim as any}> {ui.status}</Text>}
</NoSelect>
<ComposerPane actions={actions} composer={composer} status={status} />
</Box>
</AlternateScreen>
)
}
})

View file

@ -1,5 +1,5 @@
import { Box, Text } from '@hermes/ink'
import type { ReactNode } from 'react'
import { memo, type ReactNode, useMemo } from 'react'
import type { Theme } from '../theme.js'
@ -212,367 +212,379 @@ function MdInline({ t, text }: { t: Theme; text: string }) {
return <Text>{parts.length ? parts : <Text>{text}</Text>}</Text>
}
export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: string }) {
const lines = text.split('\n')
const nodes: ReactNode[] = []
let i = 0
interface MdProps {
compact?: boolean
t: Theme
text: string
}
let prevKind: 'blank' | 'code' | 'heading' | 'list' | 'paragraph' | 'quote' | 'rule' | 'table' | null = null
function MdImpl({ compact, t, text }: MdProps) {
const nodes = useMemo(() => {
const lines = text.split('\n')
const nodes: ReactNode[] = []
let i = 0
const gap = () => {
if (nodes.length && prevKind !== 'blank') {
nodes.push(<Text key={`gap-${nodes.length}`}> </Text>)
prevKind = 'blank'
}
}
let prevKind: 'blank' | 'code' | 'heading' | 'list' | 'paragraph' | 'quote' | 'rule' | 'table' | null = null
const start = (kind: Exclude<typeof prevKind, null | 'blank'>) => {
if (prevKind && prevKind !== 'blank' && prevKind !== kind) {
gap()
const gap = () => {
if (nodes.length && prevKind !== 'blank') {
nodes.push(<Text key={`gap-${nodes.length}`}> </Text>)
prevKind = 'blank'
}
}
prevKind = kind
}
while (i < lines.length) {
const line = lines[i]!
const key = nodes.length
if (compact && !line.trim()) {
i++
continue
}
if (!line.trim()) {
gap()
i++
continue
}
const fence = parseFence(line)
if (fence) {
const block: string[] = []
const lang = fence.lang
for (i++; i < lines.length && !isFenceClose(lines[i]!, fence); i++) {
block.push(lines[i]!)
const start = (kind: Exclude<typeof prevKind, null | 'blank'>) => {
if (prevKind && prevKind !== 'blank' && prevKind !== kind) {
gap()
}
if (i < lines.length) {
prevKind = kind
}
while (i < lines.length) {
const line = lines[i]!
const key = nodes.length
if (compact && !line.trim()) {
i++
}
if (isMarkdownFence(lang)) {
start('paragraph')
nodes.push(<Md compact={compact} key={key} t={t} text={block.join('\n')} />)
continue
}
start('code')
if (!line.trim()) {
gap()
i++
const isDiff = lang === 'diff'
nodes.push(
<Box flexDirection="column" key={key} paddingLeft={2}>
{lang && !isDiff && <Text color={t.color.dim}>{'─ ' + lang}</Text>}
{block.map((l, j) => {
const add = isDiff && l.startsWith('+')
const del = isDiff && l.startsWith('-')
const hunk = isDiff && l.startsWith('@@')
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}
dimColor={isDiff && !add && !del && !hunk && l.startsWith(' ')}
key={j}
>
{l}
</Text>
)
})}
</Box>
)
continue
}
if (line.trim().startsWith('$$')) {
start('code')
const block: string[] = []
for (i++; i < lines.length; i++) {
if (lines[i]!.trim().startsWith('$$')) {
i++
break
}
block.push(lines[i]!)
continue
}
nodes.push(
<Box flexDirection="column" key={key} paddingLeft={2}>
<Text color={t.color.dim}> math</Text>
{block.map((l, j) => (
<Text color={t.color.amber} key={j}>
{l}
</Text>
))}
</Box>
)
const fence = parseFence(line)
continue
}
if (fence) {
const block: string[] = []
const lang = fence.lang
const heading = line.match(HEADING_RE)
for (i++; i < lines.length && !isFenceClose(lines[i]!, fence); i++) {
block.push(lines[i]!)
}
if (heading) {
start('heading')
nodes.push(
<Text bold color={t.color.amber} key={key}>
{heading[2]}
</Text>
)
i++
if (i < lines.length) {
i++
}
continue
}
if (isMarkdownFence(lang)) {
start('paragraph')
nodes.push(<Md compact={compact} key={key} t={t} text={block.join('\n')} />)
if (i + 1 < lines.length && line.trim()) {
const setext = lines[i + 1]!.match(/^\s{0,3}(=+|-+)\s*$/)
continue
}
if (setext) {
start('code')
const isDiff = lang === 'diff'
nodes.push(
<Box flexDirection="column" key={key} paddingLeft={2}>
{lang && !isDiff && <Text color={t.color.dim}>{'─ ' + lang}</Text>}
{block.map((l, j) => {
const add = isDiff && l.startsWith('+')
const del = isDiff && l.startsWith('-')
const hunk = isDiff && l.startsWith('@@')
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}
dimColor={isDiff && !add && !del && !hunk && l.startsWith(' ')}
key={j}
>
{l}
</Text>
)
})}
</Box>
)
continue
}
if (line.trim().startsWith('$$')) {
start('code')
const block: string[] = []
for (i++; i < lines.length; i++) {
if (lines[i]!.trim().startsWith('$$')) {
i++
break
}
block.push(lines[i]!)
}
nodes.push(
<Box flexDirection="column" key={key} paddingLeft={2}>
<Text color={t.color.dim}> math</Text>
{block.map((l, j) => (
<Text color={t.color.amber} key={j}>
{l}
</Text>
))}
</Box>
)
continue
}
const heading = line.match(HEADING_RE)
if (heading) {
start('heading')
nodes.push(
<Text bold color={t.color.amber} key={key}>
{line.trim()}
{heading[2]}
</Text>
)
i += 2
i++
continue
}
}
if (HR_RE.test(line)) {
start('rule')
nodes.push(
<Text color={t.color.dim} key={key}>
{'─'.repeat(36)}
</Text>
)
i++
if (i + 1 < lines.length && line.trim()) {
const setext = lines[i + 1]!.match(/^\s{0,3}(=+|-+)\s*$/)
continue
}
const footnote = line.match(FOOTNOTE_RE)
if (footnote) {
start('list')
nodes.push(
<Text color={t.color.dim} key={key}>
[{footnote[1]}] <MdInline t={t} text={footnote[2] ?? ''} />
</Text>
)
i++
while (i < lines.length && /^\s{2,}\S/.test(lines[i]!)) {
nodes.push(
<Box key={`${key}-cont-${i}`} paddingLeft={2}>
<Text color={t.color.dim}>
<MdInline t={t} text={lines[i]!.trim()} />
if (setext) {
start('heading')
nodes.push(
<Text bold color={t.color.amber} key={key}>
{line.trim()}
</Text>
)
i += 2
continue
}
}
if (HR_RE.test(line)) {
start('rule')
nodes.push(
<Text color={t.color.dim} key={key}>
{'─'.repeat(36)}
</Text>
)
i++
continue
}
const footnote = line.match(FOOTNOTE_RE)
if (footnote) {
start('list')
nodes.push(
<Text color={t.color.dim} key={key}>
[{footnote[1]}] <MdInline t={t} text={footnote[2] ?? ''} />
</Text>
)
i++
while (i < lines.length && /^\s{2,}\S/.test(lines[i]!)) {
nodes.push(
<Box key={`${key}-cont-${i}`} paddingLeft={2}>
<Text color={t.color.dim}>
<MdInline t={t} text={lines[i]!.trim()} />
</Text>
</Box>
)
i++
}
continue
}
if (i + 1 < lines.length && DEF_RE.test(lines[i + 1]!)) {
start('list')
nodes.push(
<Text bold key={key}>
{line.trim()}
</Text>
)
i++
while (i < lines.length) {
const def = lines[i]!.match(DEF_RE)
if (!def) {
break
}
nodes.push(
<Text key={`${key}-def-${i}`}>
<Text color={t.color.dim}> · </Text>
<MdInline t={t} text={def[1]!} />
</Text>
)
i++
}
continue
}
const bullet = line.match(/^(\s*)[-+*]\s+(.*)$/)
if (bullet) {
start('list')
const depth = indentDepth(bullet[1]!)
const task = bullet[2]!.match(/^\[( |x|X)\]\s+(.*)$/)
const marker = task ? (task[1]!.toLowerCase() === 'x' ? '☑' : '☐') : '•'
const body = task ? task[2]! : bullet[2]!
nodes.push(
<Text key={key}>
<Text color={t.color.dim}>
{' '.repeat(depth * 2)}
{marker}{' '}
</Text>
<MdInline t={t} text={body} />
</Text>
)
i++
continue
}
const numbered = line.match(/^(\s*)(\d+)[.)]\s+(.*)$/)
if (numbered) {
start('list')
const depth = indentDepth(numbered[1]!)
nodes.push(
<Text key={key}>
<Text color={t.color.dim}>
{' '.repeat(depth * 2)}
{numbered[2]}.{' '}
</Text>
<MdInline t={t} text={numbered[3]!} />
</Text>
)
i++
continue
}
if (/^\s*(?:>\s*)+/.test(line)) {
start('quote')
const quoteLines: Array<{ depth: number; text: string }> = []
while (i < lines.length && /^\s*(?:>\s*)+/.test(lines[i]!)) {
const raw = lines[i]!
const prefix = raw.match(/^\s*(?:>\s*)+/)?.[0] ?? ''
quoteLines.push({
depth: (prefix.match(/>/g) ?? []).length,
text: raw.slice(prefix.length)
})
i++
}
nodes.push(
<Box flexDirection="column" key={key}>
{quoteLines.map((ql, qi) => (
<Text color={t.color.dim} key={qi}>
{' '.repeat(Math.max(0, ql.depth - 1) * 2)}
{'│ '}
<MdInline t={t} text={ql.text} />
</Text>
))}
</Box>
)
i++
continue
}
continue
}
if (line.includes('|') && i + 1 < lines.length && isTableDivider(lines[i + 1]!)) {
start('table')
const tableRows: string[][] = []
if (i + 1 < lines.length && DEF_RE.test(lines[i + 1]!)) {
start('list')
nodes.push(
<Text bold key={key}>
{line.trim()}
</Text>
)
i++
tableRows.push(splitTableRow(line))
i += 2
while (i < lines.length) {
const def = lines[i]!.match(DEF_RE)
if (!def) {
break
while (i < lines.length && lines[i]!.includes('|') && lines[i]!.trim()) {
tableRows.push(splitTableRow(lines[i]!))
i++
}
nodes.push(renderTable(key, tableRows, t))
continue
}
if (/^<details\b/i.test(line) || /^<\/details>/i.test(line)) {
i++
continue
}
const summary = line.match(/^<summary>(.*?)<\/summary>$/i)
if (summary) {
start('paragraph')
nodes.push(
<Text key={`${key}-def-${i}`}>
<Text color={t.color.dim}> · </Text>
<MdInline t={t} text={def[1]!} />
<Text color={t.color.dim} key={key}>
{summary[1]}
</Text>
)
i++
continue
}
continue
}
const bullet = line.match(/^(\s*)[-+*]\s+(.*)$/)
if (bullet) {
start('list')
const depth = indentDepth(bullet[1]!)
const task = bullet[2]!.match(/^\[( |x|X)\]\s+(.*)$/)
const marker = task ? (task[1]!.toLowerCase() === 'x' ? '☑' : '☐') : '•'
const body = task ? task[2]! : bullet[2]!
nodes.push(
<Text key={key}>
<Text color={t.color.dim}>
{' '.repeat(depth * 2)}
{marker}{' '}
if (/^<\/?[^>]+>$/.test(line.trim())) {
start('paragraph')
nodes.push(
<Text color={t.color.dim} key={key}>
{line.trim()}
</Text>
<MdInline t={t} text={body} />
</Text>
)
i++
continue
}
const numbered = line.match(/^(\s*)(\d+)[.)]\s+(.*)$/)
if (numbered) {
start('list')
const depth = indentDepth(numbered[1]!)
nodes.push(
<Text key={key}>
<Text color={t.color.dim}>
{' '.repeat(depth * 2)}
{numbered[2]}.{' '}
</Text>
<MdInline t={t} text={numbered[3]!} />
</Text>
)
i++
continue
}
if (/^\s*(?:>\s*)+/.test(line)) {
start('quote')
const quoteLines: Array<{ depth: number; text: string }> = []
while (i < lines.length && /^\s*(?:>\s*)+/.test(lines[i]!)) {
const raw = lines[i]!
const prefix = raw.match(/^\s*(?:>\s*)+/)?.[0] ?? ''
quoteLines.push({
depth: (prefix.match(/>/g) ?? []).length,
text: raw.slice(prefix.length)
})
)
i++
continue
}
nodes.push(
<Box flexDirection="column" key={key}>
{quoteLines.map((ql, qi) => (
<Text color={t.color.dim} key={qi}>
{' '.repeat(Math.max(0, ql.depth - 1) * 2)}
{'│ '}
<MdInline t={t} text={ql.text} />
</Text>
))}
</Box>
)
if (line.includes('|') && line.trim().startsWith('|')) {
start('table')
const tableRows: string[][] = []
continue
}
while (i < lines.length && lines[i]!.trim().startsWith('|')) {
const row = lines[i]!.trim()
if (line.includes('|') && i + 1 < lines.length && isTableDivider(lines[i + 1]!)) {
start('table')
const tableRows: string[][] = []
if (!/^[|\s:-]+$/.test(row)) {
tableRows.push(splitTableRow(row))
}
tableRows.push(splitTableRow(line))
i += 2
while (i < lines.length && lines[i]!.includes('|') && lines[i]!.trim()) {
tableRows.push(splitTableRow(lines[i]!))
i++
}
nodes.push(renderTable(key, tableRows, t))
continue
}
if (/^<details\b/i.test(line) || /^<\/details>/i.test(line)) {
i++
continue
}
const summary = line.match(/^<summary>(.*?)<\/summary>$/i)
if (summary) {
start('paragraph')
nodes.push(
<Text color={t.color.dim} key={key}>
{summary[1]}
</Text>
)
i++
continue
}
if (/^<\/?[^>]+>$/.test(line.trim())) {
start('paragraph')
nodes.push(
<Text color={t.color.dim} key={key}>
{line.trim()}
</Text>
)
i++
continue
}
if (line.includes('|') && line.trim().startsWith('|')) {
start('table')
const tableRows: string[][] = []
while (i < lines.length && lines[i]!.trim().startsWith('|')) {
const row = lines[i]!.trim()
if (!/^[|\s:-]+$/.test(row)) {
tableRows.push(splitTableRow(row))
i++
}
i++
if (tableRows.length) {
nodes.push(renderTable(key, tableRows, t))
}
continue
}
if (tableRows.length) {
nodes.push(renderTable(key, tableRows, t))
}
start('paragraph')
nodes.push(<MdInline key={key} t={t} text={line} />)
continue
i++
}
start('paragraph')
nodes.push(<MdInline key={key} t={t} text={line} />)
i++
}
return nodes
}, [compact, t, text])
return <Box flexDirection="column">{nodes}</Box>
}
export const Md = memo(MdImpl)

View file

@ -2,18 +2,10 @@ import { Box, Text, useInput } from '@hermes/ink'
import { useEffect, useState } from 'react'
import type { GatewayClient } from '../gatewayClient.js'
import type { ModelOptionProvider, ModelOptionsResponse } from '../gatewayTypes.js'
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
import type { Theme } from '../theme.js'
interface ProviderItem {
is_current?: boolean
models?: string[]
name: string
slug: string
total_models?: number
warning?: string
}
const VISIBLE = 12
const pageOffset = (count: number, sel: number) => Math.max(0, Math.min(sel - Math.floor(VISIBLE / 2), count - VISIBLE))
@ -31,7 +23,7 @@ export function ModelPicker({
sessionId: string | null
t: Theme
}) {
const [providers, setProviders] = useState<ProviderItem[]>([])
const [providers, setProviders] = useState<ModelOptionProvider[]>([])
const [currentModel, setCurrentModel] = useState('')
const [err, setErr] = useState('')
const [loading, setLoading] = useState(true)
@ -41,9 +33,9 @@ export function ModelPicker({
const [stage, setStage] = useState<'model' | 'provider'>('provider')
useEffect(() => {
gw.request('model.options', sessionId ? { session_id: sessionId } : {})
.then((raw: any) => {
const r = asRpcResult(raw)
gw.request<ModelOptionsResponse>('model.options', sessionId ? { session_id: sessionId } : {})
.then(raw => {
const r = asRpcResult<ModelOptionsResponse>(raw)
if (!r) {
setErr('invalid response: model.options')
@ -52,7 +44,7 @@ export function ModelPicker({
return
}
const next = (r.providers ?? []) as ProviderItem[]
const next = r.providers ?? []
setProviders(next)
setCurrentModel(String(r.model ?? ''))
setProviderIdx(

View file

@ -2,18 +2,10 @@ import { Box, Text, useInput } from '@hermes/ink'
import { useEffect, useState } from 'react'
import type { GatewayClient } from '../gatewayClient.js'
import type { SessionListItem, SessionListResponse } from '../gatewayTypes.js'
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
import type { Theme } from '../theme.js'
interface SessionItem {
id: string
title: string
preview: string
started_at: number
message_count: number
source?: string
}
function age(ts: number): string {
const d = (Date.now() / 1000 - ts) / 86400
@ -41,15 +33,15 @@ export function SessionPicker({
onSelect: (id: string) => void
t: Theme
}) {
const [items, setItems] = useState<SessionItem[]>([])
const [items, setItems] = useState<SessionListItem[]>([])
const [err, setErr] = useState('')
const [sel, setSel] = useState(0)
const [loading, setLoading] = useState(true)
useEffect(() => {
gw.request('session.list', { limit: 20 })
.then((raw: any) => {
const r = asRpcResult(raw)
gw.request<SessionListResponse>('session.list', { limit: 20 })
.then(raw => {
const r = asRpcResult<SessionListResponse>(raw)
if (!r) {
setErr('invalid response: session.list')
@ -58,7 +50,7 @@ export function SessionPicker({
return
}
setItems((r?.sessions ?? []) as SessionItem[])
setItems(r.sessions ?? [])
setErr('')
setLoading(false)
})

View file

@ -0,0 +1,15 @@
import { Box, NoSelect } from '@hermes/ink'
import type { Theme } from '../theme.js'
import type { WidgetSpec } from '../widgets.js'
import { WidgetHost } from '../widgets.js'
export function SidebarRail({ t, widgets, width }: { t: Theme; widgets: WidgetSpec[]; width: number }) {
return (
<NoSelect flexDirection="column" flexShrink={0} width={width}>
<Box borderColor={t.color.bronze as any} borderStyle="round" flexDirection="column" paddingX={2} paddingY={1}>
<WidgetHost region="sidebar" widgets={widgets} />
</Box>
</NoSelect>
)
}

View file

@ -29,8 +29,16 @@ const dim = (s: string) => DIM + s + DIM_OFF
let _seg: Intl.Segmenter | null = null
const seg = () => (_seg ??= new Intl.Segmenter(undefined, { granularity: 'grapheme' }))
const STOP_CACHE_MAX = 32
const stopCache = new Map<string, number[]>()
function graphemeStops(s: string) {
const hit = stopCache.get(s)
if (hit) {
return hit
}
const stops = [0]
for (const { index } of seg().segment(s)) {
@ -43,6 +51,16 @@ function graphemeStops(s: string) {
stops.push(s.length)
}
stopCache.set(s, stops)
if (stopCache.size > STOP_CACHE_MAX) {
const oldest = stopCache.keys().next().value
if (oldest !== undefined) {
stopCache.delete(oldest)
}
}
return stops
}

View file

@ -3,6 +3,7 @@ import { memo, type ReactNode, useEffect, useMemo, useState } from 'react'
import spinners, { type BrailleSpinnerName } from 'unicode-animations'
import {
compactPreview,
estimateTokensRough,
fmtK,
formatToolCall,
@ -13,7 +14,7 @@ import {
toolTrailLabel
} from '../lib/text.js'
import type { Theme } from '../theme.js'
import type { ActiveTool, ActivityItem, DetailsMode, ThinkingMode } from '../types.js'
import type { ActiveTool, ActivityItem, DetailsMode, SubagentProgress, ThinkingMode } from '../types.js'
const THINK: BrailleSpinnerName[] = ['helix', 'breathe', 'orbit', 'dna', 'waverows', 'snake', 'pulse']
const TOOL: BrailleSpinnerName[] = ['cascade', 'scan', 'diagswipe', 'fillsweep', 'rain', 'columns', 'sparkle']
@ -128,6 +129,122 @@ function Chevron({
)
}
function SubagentAccordion({ expanded, item, t }: { expanded: boolean; item: SubagentProgress; t: Theme }) {
const [open, setOpen] = useState(expanded)
const [openThinking, setOpenThinking] = useState(expanded)
const [openTools, setOpenTools] = useState(expanded)
const [openNotes, setOpenNotes] = useState(expanded)
useEffect(() => {
if (!expanded) {
return
}
setOpen(true)
setOpenThinking(true)
setOpenTools(true)
setOpenNotes(true)
}, [expanded])
const statusTone: 'dim' | 'error' | 'warn' =
item.status === 'failed' ? 'error' : item.status === 'interrupted' ? 'warn' : 'dim'
const prefix = item.taskCount > 1 ? `[${item.index + 1}/${item.taskCount}] ` : ''
const title = `${prefix}${item.goal || `Subagent ${item.index + 1}`}`
const summary = compactPreview((item.summary || '').replace(/\s+/g, ' ').trim(), 72)
const suffix =
item.status === 'running'
? 'running'
: `${item.status}${item.durationSeconds ? ` · ${fmtElapsed(item.durationSeconds * 1000)}` : ''}`
const thinkingText = item.thinking.join('\n')
const hasThinking = Boolean(thinkingText)
const hasTools = item.tools.length > 0
const noteRows = [...(summary ? [summary] : []), ...item.notes]
const hasNotes = noteRows.length > 0
const active = expanded || open
return (
<Box flexDirection="column" paddingLeft={1}>
<Chevron onClick={() => setOpen(v => !v)} open={active} suffix={suffix} t={t} title={title} tone={statusTone} />
{active && (
<Box flexDirection="column" paddingLeft={2}>
{hasThinking && (
<>
<Chevron
count={item.thinking.length}
onClick={() => setOpenThinking(v => !v)}
open={expanded || openThinking}
t={t}
title="Thinking"
/>
{(expanded || openThinking) && (
<Thinking
active={item.status === 'running'}
mode="full"
reasoning={thinkingText}
streaming={item.status === 'running'}
t={t}
/>
)}
</>
)}
{hasTools && (
<>
<Chevron
count={item.tools.length}
onClick={() => setOpenTools(v => !v)}
open={expanded || openTools}
t={t}
title="Tool calls"
/>
{(expanded || openTools) && (
<Box flexDirection="column">
{item.tools.map((line, index) => (
<Text color={t.color.cornsilk} key={`${item.id}-tool-${index}`} wrap="wrap-trim">
<Text color={t.color.amber}> </Text>
{line}
</Text>
))}
</Box>
)}
</>
)}
{hasNotes && (
<>
<Chevron
count={noteRows.length}
onClick={() => setOpenNotes(v => !v)}
open={expanded || openNotes}
t={t}
title="Progress"
tone={statusTone}
/>
{(expanded || openNotes) && (
<Box flexDirection="column">
{noteRows.map((line, index) => (
<Text
color={statusTone === 'error' ? t.color.error : t.color.dim}
dimColor
key={`${item.id}-note-${index}`}
>
<Text dimColor>{index === noteRows.length - 1 ? '└ ' : '├ '}</Text>
{line}
</Text>
))}
</Box>
)}
</>
)}
</Box>
)}
</Box>
)
}
// ── Thinking ─────────────────────────────────────────────────────────
export const Thinking = memo(function Thinking({
@ -143,7 +260,7 @@ export const Thinking = memo(function Thinking({
streaming?: boolean
t: Theme
}) {
const preview = thinkingPreview(reasoning, mode, THINKING_COT_MAX)
const preview = useMemo(() => thinkingPreview(reasoning, mode, THINKING_COT_MAX), [mode, reasoning])
const lines = useMemo(() => preview.split('\n').map(line => line.replace(/\t/g, ' ')), [preview])
return (
@ -198,6 +315,7 @@ export const ToolTrail = memo(function ToolTrail({
reasoning = '',
reasoningTokens,
reasoningStreaming = false,
subagents = [],
t,
tools = [],
toolTokens,
@ -210,6 +328,7 @@ export const ToolTrail = memo(function ToolTrail({
reasoning?: string
reasoningTokens?: number
reasoningStreaming?: boolean
subagents?: SubagentProgress[]
t: Theme
tools?: ActiveTool[]
toolTokens?: number
@ -219,6 +338,7 @@ export const ToolTrail = memo(function ToolTrail({
const [now, setNow] = useState(() => Date.now())
const [openThinking, setOpenThinking] = useState(false)
const [openTools, setOpenTools] = useState(false)
const [openSubagents, setOpenSubagents] = useState(false)
const [openMeta, setOpenMeta] = useState(false)
useEffect(() => {
@ -235,19 +355,21 @@ export const ToolTrail = memo(function ToolTrail({
if (detailsMode === 'expanded') {
setOpenThinking(true)
setOpenTools(true)
setOpenSubagents(true)
setOpenMeta(true)
}
if (detailsMode === 'hidden') {
setOpenThinking(false)
setOpenTools(false)
setOpenSubagents(false)
setOpenMeta(false)
}
}, [detailsMode])
const cot = thinkingPreview(reasoning, 'full', THINKING_COT_MAX)
const cot = useMemo(() => thinkingPreview(reasoning, 'full', THINKING_COT_MAX), [reasoning])
if (!busy && !trail.length && !tools.length && !activity.length && !cot && !reasoningActive) {
if (!busy && !trail.length && !tools.length && !subagents.length && !activity.length && !cot && !reasoningActive) {
return null
}
@ -334,6 +456,7 @@ export const ToolTrail = memo(function ToolTrail({
// ── Derived ────────────────────────────────────────────────────
const hasTools = groups.length > 0
const hasSubagents = subagents.length > 0
const hasMeta = meta.length > 0
const hasThinking = !!cot || reasoningActive || (busy && !hasTools)
const thinkingLive = reasoningActive || reasoningStreaming
@ -395,6 +518,10 @@ export const ToolTrail = memo(function ToolTrail({
))
: null
const subagentBlock = hasSubagents
? subagents.map(item => <SubagentAccordion expanded={detailsMode === 'expanded'} item={item} key={item.id} t={t} />)
: null
const metaBlock = hasMeta
? meta.map((row, i) => (
<Text color={row.color} dimColor={row.dimColor} key={row.key}>
@ -418,6 +545,7 @@ export const ToolTrail = memo(function ToolTrail({
<Box flexDirection="column">
{thinkingBlock}
{toolBlock}
{subagentBlock}
{metaBlock}
{totalBlock}
</Box>
@ -468,6 +596,19 @@ export const ToolTrail = memo(function ToolTrail({
</>
)}
{hasSubagents && (
<>
<Chevron
count={subagents.length}
onClick={() => setOpenSubagents(v => !v)}
open={openSubagents}
t={t}
title="Subagents"
/>
{openSubagents && subagentBlock}
</>
)}
{hasMeta && (
<>
<Chevron

View file

@ -4,6 +4,8 @@ import { existsSync } from 'node:fs'
import { delimiter, resolve } from 'node:path'
import { createInterface } from 'node:readline'
import type { GatewayEvent } from './gatewayTypes.js'
const MAX_GATEWAY_LOG_LINES = 200
const MAX_LOG_PREVIEW = 240
const STARTUP_TIMEOUT_MS = Math.max(5000, parseInt(process.env.HERMES_TUI_STARTUP_TIMEOUT_MS ?? '15000', 10) || 15000)
@ -42,10 +44,12 @@ const resolvePython = (root: string) => {
return process.platform === 'win32' ? 'python' : 'python3'
}
export interface GatewayEvent {
type: string
session_id?: string
payload?: Record<string, unknown>
const asGatewayEvent = (value: unknown): GatewayEvent | null => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null
}
return typeof (value as { type?: unknown }).type === 'string' ? (value as GatewayEvent) : null
}
interface Pending {
@ -174,13 +178,23 @@ export class GatewayClient extends EventEmitter {
if (p) {
this.pending.delete(id!)
msg.error ? p.reject(new Error((msg.error as any).message)) : p.resolve(msg.result)
if (msg.error) {
const err = msg.error as { message?: unknown } | null | undefined
p.reject(new Error(typeof err?.message === 'string' ? err.message : 'request failed'))
} else {
p.resolve(msg.result)
}
return
}
if (msg.method === 'event') {
this.publish(msg.params as GatewayEvent)
const ev = asGatewayEvent(msg.params)
if (ev) {
this.publish(ev)
}
}
}
@ -218,7 +232,7 @@ export class GatewayClient extends EventEmitter {
return this.logs.slice(-Math.max(1, limit)).join('\n')
}
request(method: string, params: Record<string, unknown> = {}): Promise<unknown> {
request<T = unknown>(method: string, params: Record<string, unknown> = {}): Promise<T> {
if (!this.proc?.stdin || this.proc.killed || this.proc.exitCode !== null) {
this.start()
}
@ -243,7 +257,7 @@ export class GatewayClient extends EventEmitter {
},
resolve: v => {
clearTimeout(timeout)
resolve(v)
resolve(v as T)
}
})

198
ui-tui/src/gatewayTypes.ts Normal file
View file

@ -0,0 +1,198 @@
import type { SessionInfo, SlashCategory, Usage } from './types.js'
export interface GatewaySkin {
banner_hero?: string
banner_logo?: string
branding?: Record<string, string>
colors?: Record<string, string>
}
export interface GatewayCompletionItem {
display: string
meta?: string
text: string
}
export interface GatewayTranscriptMessage {
context?: string
name?: string
role: 'assistant' | 'system' | 'tool' | 'user'
text?: string
}
export interface CommandsCatalogResponse {
canon?: Record<string, string>
categories?: SlashCategory[]
pairs?: [string, string][]
skill_count?: number
sub?: Record<string, string[]>
warning?: string
}
export interface CompletionResponse {
items?: GatewayCompletionItem[]
replace_from?: number
}
export interface ConfigDisplayConfig {
bell_on_complete?: boolean
details_mode?: string
thinking_mode?: string
tui_compact?: boolean
tui_statusbar?: boolean
}
export interface ConfigFullResponse {
config?: {
display?: ConfigDisplayConfig
}
}
export interface ConfigMtimeResponse {
mtime?: number
}
export interface BackgroundStartResponse {
task_id?: string
}
export interface SessionCreateResponse {
info?: SessionInfo & { credential_warning?: string }
session_id: string
}
export interface SessionResumeResponse {
info?: SessionInfo
message_count?: number
messages: GatewayTranscriptMessage[]
resumed?: string
session_id: string
}
export interface SessionListItem {
id: string
message_count: number
preview: string
source?: string
started_at: number
title: string
}
export interface SessionListResponse {
sessions?: SessionListItem[]
}
export interface SessionUndoResponse {
removed?: number
}
export interface SessionHistoryResponse {
count?: number
messages?: GatewayTranscriptMessage[]
}
export interface ModelOptionProvider {
is_current?: boolean
models?: string[]
name: string
slug: string
total_models?: number
warning?: string
}
export interface ModelOptionsResponse {
model?: string
provider?: string
providers?: ModelOptionProvider[]
}
export interface ToolsetDetails {
description: string
enabled: boolean
name: string
tool_count: number
tools: string[]
}
export interface ToolsListResponse {
toolsets?: ToolsetDetails[]
}
export interface ToolSummary {
description: string
name: string
}
export interface ToolsShowSection {
name: string
tools: ToolSummary[]
}
export interface ToolsShowResponse {
sections?: ToolsShowSection[]
total?: number
}
export interface ToolsConfigureResponse {
changed?: string[]
enabled_toolsets?: string[]
info?: SessionInfo
missing_servers?: string[]
reset?: boolean
unknown?: string[]
}
export interface SlashExecResponse {
output?: string
warning?: string
}
export interface SubagentEventPayload {
duration_seconds?: number
goal: string
status?: 'completed' | 'failed' | 'interrupted' | 'running'
summary?: string
task_count?: number
task_index: number
text?: string
tool_name?: string
tool_preview?: string
}
export type GatewayEvent =
| { payload?: { skin?: GatewaySkin }; session_id?: string; type: 'gateway.ready' }
| { 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?: undefined; session_id?: string; type: 'message.start' }
| { payload?: { kind?: string; text?: string }; session_id?: string; type: 'status.update' }
| { payload: { line: string }; session_id?: string; type: 'gateway.stderr' }
| { payload?: { cwd?: string; python?: string }; session_id?: string; type: 'gateway.start_timeout' }
| { payload?: { preview?: string }; session_id?: string; type: 'gateway.protocol_error' }
| { payload?: { text?: string }; session_id?: string; type: 'reasoning.delta' | 'reasoning.available' }
| { payload: { name?: string; preview?: string }; session_id?: string; type: 'tool.progress' }
| { payload: { name?: string }; session_id?: string; type: 'tool.generating' }
| { payload: { context?: string; name?: string; tool_id: string }; session_id?: string; type: 'tool.start' }
| {
payload: { error?: string; inline_diff?: string; name?: string; summary?: string; tool_id: string }
session_id?: string
type: 'tool.complete'
}
| {
payload: { choices: string[] | null; question: string; request_id: string }
session_id?: string
type: 'clarify.request'
}
| { payload: { command: string; description: string }; session_id?: string; type: 'approval.request' }
| { payload: { request_id: string }; session_id?: string; type: 'sudo.request' }
| { payload: { env_var: string; prompt: string; request_id: string }; session_id?: string; type: 'secret.request' }
| { payload: { task_id: string; text: string }; session_id?: string; type: 'background.complete' }
| { payload: { text: string }; session_id?: string; type: 'btw.complete' }
| { payload: SubagentEventPayload; session_id?: string; type: 'subagent.start' }
| { payload: SubagentEventPayload; session_id?: string; type: 'subagent.thinking' }
| { payload: SubagentEventPayload; session_id?: string; type: 'subagent.tool' }
| { payload: SubagentEventPayload; session_id?: string; type: 'subagent.progress' }
| { payload: SubagentEventPayload; session_id?: string; type: 'subagent.complete' }
| { payload: { rendered?: string; text?: string }; session_id?: string; type: 'message.delta' }
| { payload?: { rendered?: string; text?: string; usage?: Usage }; session_id?: string; type: 'message.complete' }
| { payload?: { message?: string }; session_id?: string; type: 'error' }

View file

@ -2,14 +2,10 @@ import { useEffect, useRef, useState } from 'react'
import type { CompletionItem } from '../app/interfaces.js'
import type { GatewayClient } from '../gatewayClient.js'
import type { CompletionResponse } from '../gatewayTypes.js'
import { asRpcResult } from '../lib/rpc.js'
const TAB_PATH_RE = /((?:["']?(?:[A-Za-z]:[\\/]|\.{1,2}\/|~\/|\/|@|[^"'`\s]+\/))[^\s]*)$/
interface CompletionResult {
items?: CompletionItem[]
replace_from?: number
}
export function useCompletion(input: string, blocked: boolean, gw: GatewayClient) {
const [completions, setCompletions] = useState<CompletionItem[]>([])
const [compIdx, setCompIdx] = useState(0)
@ -51,12 +47,12 @@ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient
}
const req = isSlash
? gw.request('complete.slash', { text: input })
: gw.request('complete.path', { word: pathWord })
? gw.request<CompletionResponse>('complete.slash', { text: input })
: gw.request<CompletionResponse>('complete.path', { word: pathWord })
req
.then(raw => {
const r = raw as CompletionResult | null | undefined
const r = asRpcResult<CompletionResponse>(raw)
if (ref.current !== input) {
return

View file

@ -1,11 +1,11 @@
export type RpcResult = Record<string, any>
export const asRpcResult = (value: unknown): RpcResult | null => {
export const asRpcResult = <T extends RpcResult = RpcResult>(value: unknown): T | null => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null
}
return value as RpcResult
return value as T
}
export const rpcErrorMessage = (err: unknown) => {

View file

@ -11,6 +11,19 @@ export interface ActivityItem {
tone: 'error' | 'info' | 'warn'
}
export interface SubagentProgress {
durationSeconds?: number
goal: string
id: string
index: number
notes: string[]
status: 'completed' | 'failed' | 'interrupted' | 'running'
summary?: string
taskCount: number
thinking: string[]
tools: string[]
}
export interface ApprovalReq {
command: string
description: string

576
ui-tui/src/widgets.tsx Normal file
View file

@ -0,0 +1,576 @@
import { Box, Text } from '@hermes/ink'
import { Fragment, type ReactNode, useEffect, useState } from 'react'
import type { Theme } from './theme.js'
import type { Usage } from './types.js'
// ── Region types ──────────────────────────────────────────────────────
export const WIDGET_REGIONS = [
'transcript-header',
'transcript-inline',
'transcript-tail',
'dock',
'overlay',
'sidebar'
] as const
export type WidgetRegion = (typeof WIDGET_REGIONS)[number]
export interface WidgetCtx {
blocked?: boolean
bgCount: number
busy: boolean
cols: number
cwdLabel?: string
durationLabel?: string
model?: string
status: string
t: Theme
// tick is intentionally NOT here — each widget calls useWidgetTicker() internally.
// Passing tick via props caused useMemo in AppLayout to rebuild JSX on every second,
// which created stale prop snapshots and broke animated text rendering.
usage: Usage
voiceLabel?: string
}
export interface WidgetSpec {
id: string
node: ReactNode
order?: number
region: WidgetRegion
// Optional: theme transform applied to `t` before rendering. This lets
// individual widgets opt into a different color palette (e.g. Bloomberg)
// without touching the main app theme.
themeOverride?: (base: Theme) => Theme
}
export interface WidgetRenderState {
enabled?: Record<string, boolean>
params?: Record<string, Record<string, string>>
}
// ── Theme overrides ───────────────────────────────────────────────────
// Bloomberg terminal palette: high-contrast orange/green/red on dark intent.
export function bloombergTheme(t: Theme): Theme {
return {
...t,
color: {
...t.color,
gold: '#FFE000', // bright yellow titles
amber: '#FF8C00', // orange for values
bronze: '#FF6600', // orange borders
cornsilk: '#FFFFFF', // white for primary text
dim: '#777777', // gray secondary
label: '#FFCC00', // amber labels
statusGood: '#00EE00', // classic Bloomberg green
statusBad: '#FF2200', // Bloomberg red
statusWarn: '#FFAA00'
}
}
}
// ── Data ─────────────────────────────────────────────────────────────
interface Asset {
label: string
series: number[]
}
const BTC: number[] = [
83900, 84210, 85140, 84680, 85990, 86540, 87310, 86820, 87940, 88600, 89200, 90100, 90560, 91840, 91210, 92680, 92100,
91500, 92900, 93200
]
const ETH: number[] = [
2920, 2960, 3015, 2990, 3070, 3050, 3110, 3080, 3160, 3200, 3225, 3260, 3290, 3250, 3310, 3330, 3385, 3360, 3400, 3420
]
const NVDA: number[] = [
125, 128, 129, 127, 131, 130, 133, 132, 136, 137, 139, 138, 141, 140, 142, 143, 145, 144, 146, 148
]
const TSLA: number[] = [
172, 176, 178, 175, 181, 180, 184, 182, 188, 187, 191, 189, 195, 193, 196, 198, 201, 199, 203, 205
]
const TEMP_DAY: number[] = [65, 66, 67, 69, 71, 73, 74, 75, 74, 73, 72, 71, 70, 69, 68]
const USD = new Intl.NumberFormat('en-US', { currency: 'USD', maximumFractionDigits: 0, style: 'currency' })
const BARS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']
const ORBS = ['◐', '◓', '◑', '◒']
const ASSETS: Asset[] = [
{ label: 'BTC', series: BTC },
{ label: 'ETH', series: ETH },
{ label: 'NVDA', series: NVDA },
{ label: 'TSLA', series: TSLA }
]
const CLOCKS = [
{ label: 'NYC', tz: 'America/New_York' },
{ label: 'LON', tz: 'Europe/London' },
{ label: 'TKY', tz: 'Asia/Tokyo' },
{ label: 'SYD', tz: 'Australia/Sydney' }
]
const SKY_DESC = ['Overcast', 'Partly cloudy', 'Clear']
const SKY_ICON = ['☁', '⛅', '☀']
// ── Ticker ────────────────────────────────────────────────────────────
export function useWidgetTicker(ms = 1000) {
const [tick, setTick] = useState(0)
useEffect(() => {
const id = setInterval(() => setTick(v => v + 1), ms)
return () => clearInterval(id)
}, [ms])
return tick
}
// ── Pure math helpers ─────────────────────────────────────────────────
// Always resamples to exactly `width` points (no early return for small input).
// The old `if (values.length <= width) return values` caused the braille grid
// to only fill the first N pixel-columns when N < pxWidth.
function sample(values: number[], width: number): number[] {
if (!values.length || width <= 0) {
return []
}
if (width === 1) {
return [values.at(-1) ?? 0]
}
return Array.from({ length: width }, (_, i) => {
const pos = (i * (values.length - 1)) / (width - 1)
return values[Math.round(pos)] ?? values.at(-1) ?? 0
})
}
export function sparkline(values: number[], width: number): string {
const pts = sample(values, width)
if (!pts.length) {
return ''
}
const lo = Math.min(...pts)
const hi = Math.max(...pts)
if (lo === hi) {
return BARS[Math.floor(BARS.length / 2)]!.repeat(pts.length)
}
return pts
.map(v => BARS[Math.max(0, Math.min(BARS.length - 1, Math.round(((v - lo) / (hi - lo)) * (BARS.length - 1))))]!)
.join('')
}
export function wrapWindow<T>(values: T[], offset: number, width: number): T[] {
if (!values.length || width <= 0) {
return []
}
const start = ((offset % values.length) + values.length) % values.length
return Array.from({ length: width }, (_, i) => values[(start + i) % values.length]!)
}
export function marquee(text: string, offset: number, width: number): string {
if (!text || width <= 0) {
return ''
}
const gap = ' '
const source = text + gap + text
const span = text.length + gap.length
const start = ((offset % span) + span) % span
return source.slice(start, start + width).padEnd(width, ' ')
}
// ── Braille line chart ────────────────────────────────────────────────
function brailleBit(dx: number, dy: number): number {
return dx === 0 ? ([0x1, 0x2, 0x4, 0x40][dy] ?? 0) : ([0x8, 0x10, 0x20, 0x80][dy] ?? 0)
}
function drawLine(grid: boolean[][], x0: number, y0: number, x1: number, y1: number) {
let x = x0
let y = y0
const dx = Math.abs(x1 - x0)
const sx = x0 < x1 ? 1 : -1
const dy = -Math.abs(y1 - y0)
const sy = y0 < y1 ? 1 : -1
let err = dx + dy
while (true) {
if (grid[y] && x >= 0 && x < grid[y]!.length) {
grid[y]![x] = true
}
if (x === x1 && y === y1) {
return
}
const e2 = err * 2
if (e2 >= dy) {
err += dy
x += sx
}
if (e2 <= dx) {
err += dx
y += sy
}
}
}
export function plotLineRows(values: number[], width: number, height: number): string[] {
if (!values.length || width <= 0 || height <= 0) {
return []
}
const pxW = Math.max(2, width * 2)
const pxH = Math.max(4, height * 4)
const pts = sample(values, pxW)
const lo = Math.min(...pts)
const hi = Math.max(...pts)
const grid = Array.from({ length: pxH }, () => new Array<boolean>(pxW).fill(false))
const yFor = (v: number) => (hi === lo ? Math.floor((pxH - 1) / 2) : Math.round(((hi - v) / (hi - lo)) * (pxH - 1)))
let prevY = yFor(pts[0] ?? 0)
if (grid[prevY]) {
grid[prevY]![0] = true
}
for (let x = 1; x < pts.length; x++) {
const y = yFor(pts[x] ?? pts[x - 1] ?? 0)
drawLine(grid, x - 1, prevY, x, y)
prevY = y
}
return Array.from({ length: height }, (_, row) => {
const top = row * 4
return Array.from({ length: width }, (_, col) => {
const left = col * 2
let bits = 0
for (let dy = 0; dy < 4; dy++) {
for (let dx = 0; dx < 2; dx++) {
if (grid[top + dy]?.[left + dx]) {
bits |= brailleBit(dx, dy)
}
}
}
return String.fromCodePoint(0x2800 + bits)
}).join('')
})
}
// ── Domain helpers ────────────────────────────────────────────────────
function pct(now: number, start: number): number {
return !start ? 0 : ((now - start) / start) * 100
}
function deltaColor(delta: number, t: Theme): string {
return delta > 0 ? t.color.statusGood : delta < 0 ? t.color.statusBad : t.color.dim
}
function money(v: number): string {
return USD.format(v)
}
function changeStr(v: number): string {
return `${v >= 0 ? '+' : ''}${v.toFixed(1)}%`
}
export function cityTime(tz: string): string {
try {
return new Date().toLocaleTimeString('en-US', {
hour: '2-digit',
hour12: false,
minute: '2-digit',
second: '2-digit',
timeZone: tz
})
} catch {
return '--:--:--'
}
}
function fToC(f: number): number {
return Math.round(((f - 32) * 5) / 9)
}
// Smooth live points — always starts at series[0], adds an animated live
// endpoint oscillating ±0.8% so the chart never has discontinuous jumps.
export function livePoints(asset: Asset, tick: number): number[] {
const last = asset.series.at(-1) ?? 0
const phase = (tick * 0.3) % (Math.PI * 2)
const live = Math.round(last * (1 + Math.sin(phase) * 0.008))
return [...asset.series, live]
}
// ── Primitive components ──────────────────────────────────────────────
function LineChart({ color, height, values, width }: { color: any; height: number; values: number[]; width: number }) {
const rows = plotLineRows(values, width, height)
return (
<Box flexDirection="column">
{rows.map((row, i) => (
<Text color={color} key={i}>
{row}
</Text>
))}
</Box>
)
}
// Simple widget frame system:
// - bordered widgets: default card chrome
// - bleed widgets: full-surface background with internal padding
function WidgetFrame({
backgroundColor,
bordered = true,
borderColor,
children,
paddingX = 1,
paddingY = 0,
title,
titleRight,
titleTone,
t
}: {
backgroundColor?: any
bordered?: boolean
borderColor?: any
children: ReactNode
paddingX?: number
paddingY?: number
title: ReactNode
titleRight?: ReactNode
titleTone?: any
t: Theme
}) {
return (
<Box
backgroundColor={backgroundColor}
borderColor={bordered ? (borderColor ?? t.color.bronze) : undefined}
borderStyle={bordered ? 'round' : undefined}
flexDirection="column"
paddingX={paddingX}
paddingY={paddingY}
>
<Box justifyContent="space-between">
<Box flexDirection="row" flexShrink={1}>
{typeof title === 'string' || typeof title === 'number' ? (
<Text bold color={(titleTone ?? t.color.gold) as any} wrap="truncate-end">
{title}
</Text>
) : (
title
)}
</Box>
{titleRight ? (
<Box flexDirection="row" flexShrink={0} marginLeft={1}>
{typeof titleRight === 'string' || typeof titleRight === 'number' ? (
<Text color={t.color.dim as any}>{titleRight}</Text>
) : (
titleRight
)}
</Box>
) : null}
</Box>
{children}
</Box>
)
}
// ── Widgets ───────────────────────────────────────────────────────────
// Bloomberg-styled hero chart.
// Dark navy background for the chart cell so the green/red line pops.
// Custom layout (not Card) for full control over the title row structure.
// Compact single-line ticker strip for the dock region.
function TickerStrip({ cols, t, assetId }: WidgetCtx & { assetId?: string }) {
const tick = useWidgetTicker(1200)
const asset = ASSETS.find(a => a.label.toLowerCase() === assetId?.toLowerCase()) ?? ASSETS[tick % ASSETS.length]!
const pts = livePoints(asset, tick)
const last = pts.at(-1) ?? 0
const first = pts[0] ?? 0
const change = pct(last, first)
const color = deltaColor(change, t)
const sparkW = Math.max(8, Math.min(30, cols - 40))
const others = ASSETS.filter(a => a.label !== asset.label)
.map(a => {
const c = pct(a.series.at(-1) ?? 0, a.series[0] ?? 0)
return `${a.label} ${changeStr(c)}`
})
.join(' ')
return (
<Text color={t.color.dim as any} wrap="truncate-end">
<Text bold color={t.color.gold as any}>
{asset.label}
</Text>
<Text color={t.color.cornsilk as any}>{` ${money(last)} `}</Text>
<Text bold color={color as any}>
{changeStr(change)}
</Text>
<Text color={t.color.dim as any}>{` ${sparkline(pts, sparkW)} `}</Text>
<Text color={t.color.dim as any}>{others}</Text>
</Text>
)
}
function WeatherCard({ t }: WidgetCtx) {
const tick = useWidgetTicker(2000)
const skyIdx = Math.floor(tick / 8) % SKY_DESC.length
const desc = SKY_DESC[skyIdx]!
const icon = SKY_ICON[skyIdx]!
const temp = TEMP_DAY[tick % TEMP_DAY.length]!
const wind = 9 + ((tick * 2) % 7)
const hum = 64 + (tick % 8)
return (
<Box flexDirection="column">
<Text bold color={t.color.gold as any}>
Weather
</Text>
<Text color={t.color.cornsilk as any} wrap="truncate-end">{`${icon} ${fToC(temp)}C · ${desc}`}</Text>
<Text color={t.color.dim as any} wrap="truncate-end">{`Wind ${wind} km/h · Humidity ${hum}%`}</Text>
</Box>
)
}
// 2x2 clock grid in compact rows
function WorldClock({ cols, t }: WidgetCtx) {
const tick = useWidgetTicker()
const orb = ORBS[tick % ORBS.length]!
const rows = [CLOCKS.slice(0, 2), CLOCKS.slice(2, 4)] as const
const slotW = Math.max(12, Math.floor((Math.max(cols, 24) - 2) / 2))
const cell = (label: string, tz: string) => `${label} ${cityTime(tz)}`
return (
<Box flexDirection="column">
<Text bold color={t.color.gold as any}>{`${orb} World Clock`}</Text>
{rows.map((row, i) => (
<Box flexDirection="row" key={i}>
<Box marginRight={1} width={slotW}>
<Text color={t.color.cornsilk as any} wrap="truncate-end">
{cell(row[0]!.label, row[0]!.tz)}
</Text>
</Box>
<Box width={slotW}>
<Text color={t.color.cornsilk as any} wrap="truncate-end">
{cell(row[1]!.label, row[1]!.tz)}
</Text>
</Box>
</Box>
))}
</Box>
)
}
function HeartBeat({ t }: WidgetCtx) {
const tick = useWidgetTicker(700)
const bpm = 70 + (tick % 5)
return (
<Box flexDirection="column">
<Text bold color={t.color.gold as any}>
Heartbeat
</Text>
<Text color={t.color.statusBad as any}>{`❤️ ${bpm} bpm`}</Text>
</Box>
)
}
// ── Widget catalog ────────────────────────────────────────────────────
export interface WidgetDef {
id: string
description: string
region: WidgetRegion
order: number
defaultOn: boolean
params?: string[]
}
export const WIDGET_CATALOG: WidgetDef[] = [
{
id: 'ticker',
description: 'Live stock ticker strip',
region: 'dock',
order: 10,
defaultOn: true,
params: ['asset']
},
{ id: 'world-clock', description: '2x2 world clock grid', region: 'sidebar', order: 10, defaultOn: true },
{ id: 'weather', description: 'Weather conditions', region: 'sidebar', order: 20, defaultOn: true },
{ id: 'heartbeat', description: 'Heartbeat monitor', region: 'sidebar', order: 30, defaultOn: true }
]
// ── Registry ──────────────────────────────────────────────────────────
export function buildWidgets(ctx: WidgetCtx, state?: WidgetRenderState): WidgetSpec[] {
const bt = bloombergTheme(ctx.t)
const enabled = state?.enabled
const params = state?.params
const on = (id: string) => (enabled ? (enabled[id] ?? false) : true)
const param = (id: string, key: string) => params?.[id]?.[key]
const all: WidgetSpec[] = [
{
id: 'ticker',
node: <TickerStrip {...ctx} assetId={param('ticker', 'asset')} t={bt} />,
order: 10,
region: 'dock'
},
{ id: 'world-clock', node: <WorldClock {...ctx} />, order: 10, region: 'sidebar' },
{ id: 'weather', node: <WeatherCard {...ctx} />, order: 20, region: 'sidebar' },
{ id: 'heartbeat', node: <HeartBeat {...ctx} />, order: 30, region: 'sidebar' }
]
return all.filter(w => on(w.id))
}
export function widgetsInRegion(widgets: WidgetSpec[], region: WidgetRegion) {
return [...widgets].filter(w => w.region === region).sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
}
export function WidgetHost({ region, widgets }: { region: WidgetRegion; widgets: WidgetSpec[] }) {
const visible = widgetsInRegion(widgets, region)
if (!visible.length) {
return null
}
if (region === 'overlay') {
return (
<>
{visible.map(w => (
<Fragment key={w.id}>{w.node}</Fragment>
))}
</>
)
}
return (
<Box flexDirection="column">
{visible.map((w, i) => (
<Box flexDirection="column" key={w.id} marginTop={i === 0 ? 0 : 1}>
{w.node}
</Box>
))}
</Box>
)
}