diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 73ba81272f..e6b10df89a 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -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"], diff --git a/tui_gateway/server.py b/tui_gateway/server.py index e172cabc27..e3fb585135 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -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"], diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index 86489e334a..be27d5347f 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -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: { diff --git a/ui-tui/src/__tests__/widgets.test.ts b/ui-tui/src/__tests__/widgets.test.ts new file mode 100644 index 0000000000..39beef9081 --- /dev/null +++ b/ui-tui/src/__tests__/widgets.test.ts @@ -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') + }) +}) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 08b4152768..98e6149b1d 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -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([]) const [historyItems, setHistoryItems] = useState([]) 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>(() => {}) const submitRef = useRef<(value: string) => void>(() => {}) const configMtimeRef = useRef(0) + const historyItemsRef = useRef(historyItems) + const lastUserMsgRef = useRef(lastUserMsg) + const msgIdsRef = useRef(new WeakMap()) + 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( () => 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 = {}) => { + 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 = Record>( + method: string, + params: Record = {} + ) => { try { - const result = asRpcResult(await gw.request(method, params)) + const result = asRpcResult(await gw.request(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('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('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('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('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('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 ( ) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 6afd5c094f..86bacdecb6 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -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 | null = null + let toolProgressTimer: ReturnType | 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('commands.catalog', {}) + .then(r => { if (!r?.pairs) { return } setCatalog({ canon: (r.canon ?? {}) as Record, - 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 @@ -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('session.resume', { cols: colsRef.current, session_id: STARTUP_RESUME_ID }) + .then(raw => { + const r = asRpcResult(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 + } } } } diff --git a/ui-tui/src/app/createSlashHandler.ts b/ui-tui/src/app/createSlashHandler.ts index eb8fd7eb5f..55dbac86f2 100644 --- a/ui-tui/src/app/createSlashHandler.ts +++ b/ui-tui/src/app/createSlashHandler.ts @@ -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('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('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('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('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('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 ...]`) + sys(`built-in toolset: /tools ${subcommand} web`) + sys(`MCP tool: /tools ${subcommand} github:create_issue`) + + return true + } + + rpc('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( diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 549e85fe24..19756e8d35 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -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): Promise + (method: string, params?: Record): Promise } export interface GatewayServices { @@ -176,6 +177,7 @@ export interface TurnActions { setToolTokens: StateSetter setReasoningStreaming: StateSetter setStreaming: StateSetter + setSubagents: StateSetter setTools: StateSetter setTurnTrail: StateSetter } @@ -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 - setMessages: StateSetter } 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 @@ -346,7 +349,6 @@ export interface SlashHandlerContext { panel: (title: string, sections: PanelSection[]) => void send: (text: string) => void setHistoryItems: StateSetter - setMessages: StateSetter 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 diff --git a/ui-tui/src/app/useTurnState.ts b/ui-tui/src/app/useTurnState.ts index d20e252925..c927773112 100644 --- a/ui-tui/src/app/useTurnState.ts +++ b/ui-tui/src/app/useTurnState.ts @@ -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([]) const [tools, setTools] = useState([]) const [turnTrail, setTurnTrail] = useState([]) @@ -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 { diff --git a/ui-tui/src/app/widgetStore.ts b/ui-tui/src/app/widgetStore.ts new file mode 100644 index 0000000000..51fe3e821a --- /dev/null +++ b/ui-tui/src/app/widgetStore.ts @@ -0,0 +1,40 @@ +import { atom } from 'nanostores' + +import { WIDGET_CATALOG } from '../widgets.js' + +export interface WidgetState { + enabled: Record + params: Record> +} + +function defaults(): WidgetState { + const enabled: Record = {} + + for (const w of WIDGET_CATALOG) { + enabled[w.id] = w.defaultOn + } + + return { enabled, params: {} } +} + +export const $widgetState = atom(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 +} diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index bb5769f3a9..fff364689e 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -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({ [{bar}] {pctLabel} ) : null} - {durationLabel ? β”‚ {durationLabel} : null} + {sessionStartedAt ? ( + + {' β”‚ '} + + + ) : null} {voiceLabel ? β”‚ {voiceLabel} : null} {bgCount > 0 ? β”‚ {bgCount} bg : null} @@ -177,6 +195,8 @@ export function TranscriptScrollbar({ scrollRef, t }: { scrollRef: RefObject { if (!s || !scrollable) { @@ -203,25 +223,27 @@ export function TranscriptScrollbar({ scrollRef, t }: { scrollRef: RefObject 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 ( - - {scrollable ? (active ? '┃' : 'β”‚') : ' '} - - ) - })} + {!scrollable ? ( + + {' \n'.repeat(Math.max(0, vp - 1))}{' '} + + ) : ( + <> + {thumbTop > 0 ? ( + + {`${'β”‚\n'.repeat(Math.max(0, thumbTop - 1))}${thumbTop > 0 ? 'β”‚' : ''}`} + + ) : null} + {thumb > 0 ? ( + {`${'┃\n'.repeat(Math.max(0, thumb - 1))}${thumb > 0 ? '┃' : ''}`} + ) : null} + {vp - thumbTop - thumb > 0 ? ( + + {`${'β”‚\n'.repeat(Math.max(0, vp - thumbTop - thumb - 1))}${vp - thumbTop - thumb > 0 ? 'β”‚' : ''}`} + + ) : null} + + )} ) } diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 46bd330c1a..728a8fcce5 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -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) { const ui = useStore($uiState) - const isBlocked = useStore($isBlocked) const visibleHistory = transcript.virtualRows.slice(transcript.virtualHistory.start, transcript.virtualHistory.end) return ( - - - - - - {transcript.virtualHistory.topSpacer > 0 ? : null} + <> + + + {transcript.virtualHistory.topSpacer > 0 ? : null} - {visibleHistory.map(row => ( - - {row.msg.kind === 'intro' && row.msg.info ? ( - - - - - ) : row.msg.kind === 'panel' && row.msg.panelData ? ( - - ) : ( - - )} + {visibleHistory.map(row => ( + + {row.msg.kind === 'intro' && row.msg.info ? ( + + + - ))} - - {transcript.virtualHistory.bottomSpacer > 0 ? ( - - ) : null} - - {progress.showProgressArea && ( - - )} - - {progress.showStreamingArea && ( + ) : row.msg.kind === 'panel' && row.msg.panelData ? ( + + ) : ( )} - + ))} - - - + {transcript.virtualHistory.bottomSpacer > 0 ? : null} - - - - - - - {ui.bgTasks.size > 0 && ( - - {ui.bgTasks.size} background {ui.bgTasks.size === 1 ? 'task' : 'tasks'} running - + {progress.showProgressArea && ( + )} - {status.showStickyPrompt ? ( - - ↳ - {status.stickyPrompt} - - ) : ( - - )} - - - {ui.statusBar && ( - - )} - - + )} + + + + + + + + + + ) +}) + +const ComposerPane = memo(function ComposerPane({ + actions, + composer, + status +}: Pick) { + const ui = useStore($uiState) + const isBlocked = useStore($isBlocked) + + return ( + + + + {ui.bgTasks.size > 0 && ( + + {ui.bgTasks.size} background {ui.bgTasks.size === 1 ? 'task' : 'tasks'} running + + )} + + {status.showStickyPrompt ? ( + + ↳ + {status.stickyPrompt} + + ) : ( + + )} + + + {ui.statusBar && ( + + )} + + + + + {!isBlocked && ( + + {composer.inputBuf.map((line, i) => ( + + + {i === 0 ? `${ui.theme.brand.prompt} ` : ' '} + + + {line || ' '} + + ))} + + + + + {composer.inputBuf.length ? ' ' : `${ui.theme.brand.prompt} `} + + + + + + )} - {!isBlocked && ( - - {composer.inputBuf.map((line, i) => ( - - - {i === 0 ? `${ui.theme.brand.prompt} ` : ' '} - + {!composer.empty && !ui.sid && βš• {ui.status}} + + ) +}) - {line || ' '} - - ))} +export const AppLayout = memo(function AppLayout({ + actions, + composer, + mouseTracking, + progress, + status, + transcript +}: AppLayoutProps) { + return ( + + + + + - - - - {composer.inputBuf.length ? ' ' : `${ui.theme.brand.prompt} `} - - - - - - - )} - - {!composer.empty && !ui.sid && βš• {ui.status}} - + ) -} +}) diff --git a/ui-tui/src/components/markdown.tsx b/ui-tui/src/components/markdown.tsx index 3ba0114abf..865ab85796 100644 --- a/ui-tui/src/components/markdown.tsx +++ b/ui-tui/src/components/markdown.tsx @@ -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 {parts.length ? parts : {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( ) - prevKind = 'blank' - } - } + let prevKind: 'blank' | 'code' | 'heading' | 'list' | 'paragraph' | 'quote' | 'rule' | 'table' | null = null - const start = (kind: Exclude) => { - if (prevKind && prevKind !== 'blank' && prevKind !== kind) { - gap() + const gap = () => { + if (nodes.length && prevKind !== 'blank') { + nodes.push( ) + 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) => { + 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() continue } - start('code') + if (!line.trim()) { + gap() + i++ - const isDiff = lang === 'diff' - - nodes.push( - - {lang && !isDiff && {'─ ' + lang}} - {block.map((l, j) => { - const add = isDiff && l.startsWith('+') - const del = isDiff && l.startsWith('-') - const hunk = isDiff && l.startsWith('@@') - - return ( - - {l} - - ) - })} - - ) - - 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( - - ─ math - {block.map((l, j) => ( - - {l} - - ))} - - ) + 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( - - {heading[2]} - - ) - i++ + if (i < lines.length) { + i++ + } - continue - } + if (isMarkdownFence(lang)) { + start('paragraph') + nodes.push() - 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( + + {lang && !isDiff && {'─ ' + lang}} + {block.map((l, j) => { + const add = isDiff && l.startsWith('+') + const del = isDiff && l.startsWith('-') + const hunk = isDiff && l.startsWith('@@') + + return ( + + {l} + + ) + })} + + ) + + 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( + + ─ math + {block.map((l, j) => ( + + {l} + + ))} + + ) + + continue + } + + const heading = line.match(HEADING_RE) + + if (heading) { start('heading') nodes.push( - {line.trim()} + {heading[2]} ) - i += 2 + i++ continue } - } - if (HR_RE.test(line)) { - start('rule') - nodes.push( - - {'─'.repeat(36)} - - ) - 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( - - [{footnote[1]}] - - ) - i++ - - while (i < lines.length && /^\s{2,}\S/.test(lines[i]!)) { - nodes.push( - - - + if (setext) { + start('heading') + nodes.push( + + {line.trim()} + ) + i += 2 + + continue + } + } + + if (HR_RE.test(line)) { + start('rule') + nodes.push( + + {'─'.repeat(36)} + + ) + i++ + + continue + } + + const footnote = line.match(FOOTNOTE_RE) + + if (footnote) { + start('list') + nodes.push( + + [{footnote[1]}] + + ) + i++ + + while (i < lines.length && /^\s{2,}\S/.test(lines[i]!)) { + nodes.push( + + + + + + ) + i++ + } + + continue + } + + if (i + 1 < lines.length && DEF_RE.test(lines[i + 1]!)) { + start('list') + nodes.push( + + {line.trim()} + + ) + i++ + + while (i < lines.length) { + const def = lines[i]!.match(DEF_RE) + + if (!def) { + break + } + + nodes.push( + + Β· + + + ) + 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( + + + {' '.repeat(depth * 2)} + {marker}{' '} + + + + ) + i++ + + continue + } + + const numbered = line.match(/^(\s*)(\d+)[.)]\s+(.*)$/) + + if (numbered) { + start('list') + const depth = indentDepth(numbered[1]!) + + nodes.push( + + + {' '.repeat(depth * 2)} + {numbered[2]}.{' '} + + + + ) + 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( + + {quoteLines.map((ql, qi) => ( + + {' '.repeat(Math.max(0, ql.depth - 1) * 2)} + {'β”‚ '} + + + ))} ) - 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( - - {line.trim()} - - ) - 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 (/^/i.test(line)) { + i++ + + continue + } + + const summary = line.match(/^(.*?)<\/summary>$/i) + + if (summary) { + start('paragraph') nodes.push( - - Β· - + + β–Ά {summary[1]} ) 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( - - - {' '.repeat(depth * 2)} - {marker}{' '} + if (/^<\/?[^>]+>$/.test(line.trim())) { + start('paragraph') + nodes.push( + + {line.trim()} - - - ) - i++ - - continue - } - - const numbered = line.match(/^(\s*)(\d+)[.)]\s+(.*)$/) - - if (numbered) { - start('list') - const depth = indentDepth(numbered[1]!) - - nodes.push( - - - {' '.repeat(depth * 2)} - {numbered[2]}.{' '} - - - - ) - 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( - - {quoteLines.map((ql, qi) => ( - - {' '.repeat(Math.max(0, ql.depth - 1) * 2)} - {'β”‚ '} - - - ))} - - ) + 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 (/^/i.test(line)) { - i++ - - continue - } - - const summary = line.match(/^(.*?)<\/summary>$/i) - - if (summary) { - start('paragraph') - nodes.push( - - β–Ά {summary[1]} - - ) - i++ - - continue - } - - if (/^<\/?[^>]+>$/.test(line.trim())) { - start('paragraph') - nodes.push( - - {line.trim()} - - ) - 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() - continue + i++ } - start('paragraph') - nodes.push() - - i++ - } + return nodes + }, [compact, t, text]) return {nodes} } + +export const Md = memo(MdImpl) diff --git a/ui-tui/src/components/modelPicker.tsx b/ui-tui/src/components/modelPicker.tsx index 54e6733f8a..b2891661a9 100644 --- a/ui-tui/src/components/modelPicker.tsx +++ b/ui-tui/src/components/modelPicker.tsx @@ -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([]) + const [providers, setProviders] = useState([]) 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('model.options', sessionId ? { session_id: sessionId } : {}) + .then(raw => { + const r = asRpcResult(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( diff --git a/ui-tui/src/components/sessionPicker.tsx b/ui-tui/src/components/sessionPicker.tsx index b97c6dd7a4..5aeb238782 100644 --- a/ui-tui/src/components/sessionPicker.tsx +++ b/ui-tui/src/components/sessionPicker.tsx @@ -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([]) + const [items, setItems] = useState([]) 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('session.list', { limit: 20 }) + .then(raw => { + const r = asRpcResult(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) }) diff --git a/ui-tui/src/components/sidebarRail.tsx b/ui-tui/src/components/sidebarRail.tsx new file mode 100644 index 0000000000..80b52078c1 --- /dev/null +++ b/ui-tui/src/components/sidebarRail.tsx @@ -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 ( + + + + + + ) +} diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index cfb91b0597..fbbd37ccbe 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -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() 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 } diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index 7d0717c7a1..afd00e4a2b 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -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 ( + + setOpen(v => !v)} open={active} suffix={suffix} t={t} title={title} tone={statusTone} /> + {active && ( + + {hasThinking && ( + <> + setOpenThinking(v => !v)} + open={expanded || openThinking} + t={t} + title="Thinking" + /> + {(expanded || openThinking) && ( + + )} + + )} + + {hasTools && ( + <> + setOpenTools(v => !v)} + open={expanded || openTools} + t={t} + title="Tool calls" + /> + {(expanded || openTools) && ( + + {item.tools.map((line, index) => ( + + ● + {line} + + ))} + + )} + + )} + + {hasNotes && ( + <> + setOpenNotes(v => !v)} + open={expanded || openNotes} + t={t} + title="Progress" + tone={statusTone} + /> + {(expanded || openNotes) && ( + + {noteRows.map((line, index) => ( + + {index === noteRows.length - 1 ? 'β”” ' : 'β”œ '} + {line} + + ))} + + )} + + )} + + )} + + ) +} + // ── 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 => ) + : null + const metaBlock = hasMeta ? meta.map((row, i) => ( @@ -418,6 +545,7 @@ export const ToolTrail = memo(function ToolTrail({ {thinkingBlock} {toolBlock} + {subagentBlock} {metaBlock} {totalBlock} @@ -468,6 +596,19 @@ export const ToolTrail = memo(function ToolTrail({ )} + {hasSubagents && ( + <> + setOpenSubagents(v => !v)} + open={openSubagents} + t={t} + title="Subagents" + /> + {openSubagents && subagentBlock} + + )} + {hasMeta && ( <> { return process.platform === 'win32' ? 'python' : 'python3' } -export interface GatewayEvent { - type: string - session_id?: string - payload?: Record +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 = {}): Promise { + request(method: string, params: Record = {}): Promise { 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) } }) diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts new file mode 100644 index 0000000000..2f40b33c9f --- /dev/null +++ b/ui-tui/src/gatewayTypes.ts @@ -0,0 +1,198 @@ +import type { SessionInfo, SlashCategory, Usage } from './types.js' + +export interface GatewaySkin { + banner_hero?: string + banner_logo?: string + branding?: Record + colors?: Record +} + +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 + categories?: SlashCategory[] + pairs?: [string, string][] + skill_count?: number + sub?: Record + 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' } diff --git a/ui-tui/src/hooks/useCompletion.ts b/ui-tui/src/hooks/useCompletion.ts index 24f9317708..70dbb536f6 100644 --- a/ui-tui/src/hooks/useCompletion.ts +++ b/ui-tui/src/hooks/useCompletion.ts @@ -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([]) 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('complete.slash', { text: input }) + : gw.request('complete.path', { word: pathWord }) req .then(raw => { - const r = raw as CompletionResult | null | undefined + const r = asRpcResult(raw) if (ref.current !== input) { return diff --git a/ui-tui/src/lib/rpc.ts b/ui-tui/src/lib/rpc.ts index 502aab8fbf..8bfa7fe201 100644 --- a/ui-tui/src/lib/rpc.ts +++ b/ui-tui/src/lib/rpc.ts @@ -1,11 +1,11 @@ export type RpcResult = Record -export const asRpcResult = (value: unknown): RpcResult | null => { +export const asRpcResult = (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) => { diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 90eef0c630..317d33c971 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -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 diff --git a/ui-tui/src/widgets.tsx b/ui-tui/src/widgets.tsx new file mode 100644 index 0000000000..ffc9a864f8 --- /dev/null +++ b/ui-tui/src/widgets.tsx @@ -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 + params?: Record> +} + +// ── 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(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(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 ( + + {rows.map((row, i) => ( + + {row} + + ))} + + ) +} + +// 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 ( + + + + {typeof title === 'string' || typeof title === 'number' ? ( + + {title} + + ) : ( + title + )} + + {titleRight ? ( + + {typeof titleRight === 'string' || typeof titleRight === 'number' ? ( + {titleRight} + ) : ( + titleRight + )} + + ) : null} + + {children} + + ) +} + +// ── 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 ( + + + {asset.label} + + {` ${money(last)} `} + + {changeStr(change)} + + {` ${sparkline(pts, sparkW)} `} + {others} + + ) +} + +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 ( + + + Weather + + {`${icon} ${fToC(temp)}C Β· ${desc}`} + {`Wind ${wind} km/h Β· Humidity ${hum}%`} + + ) +} + +// 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 ( + + {`${orb} World Clock`} + {rows.map((row, i) => ( + + + + {cell(row[0]!.label, row[0]!.tz)} + + + + + {cell(row[1]!.label, row[1]!.tz)} + + + + ))} + + ) +} + +function HeartBeat({ t }: WidgetCtx) { + const tick = useWidgetTicker(700) + const bpm = 70 + (tick % 5) + + return ( + + + Heartbeat + + {`❀️ ${bpm} bpm`} + + ) +} + +// ── 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: , + order: 10, + region: 'dock' + }, + { id: 'world-clock', node: , order: 10, region: 'sidebar' }, + { id: 'weather', node: , order: 20, region: 'sidebar' }, + { id: 'heartbeat', node: , 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 => ( + {w.node} + ))} + + ) + } + + return ( + + {visible.map((w, i) => ( + + {w.node} + + ))} + + ) +}