mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat: just more cleaning
This commit is contained in:
parent
46cef4b7fa
commit
4b4b4d47bc
24 changed files with 2852 additions and 829 deletions
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
179
ui-tui/src/__tests__/widgets.test.ts
Normal file
179
ui-tui/src/__tests__/widgets.test.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { DEFAULT_THEME } from '../theme.js'
|
||||
import type { WidgetSpec } from '../widgets.js'
|
||||
import {
|
||||
bloombergTheme,
|
||||
buildWidgets,
|
||||
cityTime,
|
||||
livePoints,
|
||||
marquee,
|
||||
plotLineRows,
|
||||
sparkline,
|
||||
widgetsInRegion,
|
||||
wrapWindow
|
||||
} from '../widgets.js'
|
||||
|
||||
const BASE_CTX = {
|
||||
bgCount: 0,
|
||||
busy: false,
|
||||
cols: 120,
|
||||
cwdLabel: '~/hermes-agent',
|
||||
durationLabel: '11s',
|
||||
model: 'claude',
|
||||
status: 'idle',
|
||||
t: DEFAULT_THEME,
|
||||
usage: { calls: 0, input: 0, output: 0, total: 0 },
|
||||
voiceLabel: 'voice off'
|
||||
}
|
||||
|
||||
describe('sparkline', () => {
|
||||
it('respects requested width', () => {
|
||||
expect([...sparkline([1, 2, 3, 4, 5], 3)]).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('is stable for flat series', () => {
|
||||
const line = sparkline([7, 7, 7, 7], 4)
|
||||
|
||||
expect([...line]).toHaveLength(4)
|
||||
expect(new Set([...line]).size).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('widgetsInRegion', () => {
|
||||
it('filters and sorts by region + order', () => {
|
||||
const widgets: WidgetSpec[] = [
|
||||
{ id: 'c', node: 'c', order: 20, region: 'dock' },
|
||||
{ id: 'a', node: 'a', order: 5, region: 'dock' },
|
||||
{ id: 'b', node: 'b', order: 1, region: 'overlay' }
|
||||
]
|
||||
|
||||
expect(widgetsInRegion(widgets, 'dock').map(w => w.id)).toEqual(['a', 'c'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('wrapWindow', () => {
|
||||
it('wraps around the array', () => {
|
||||
expect(wrapWindow([1, 2, 3], 2, 5)).toEqual([3, 1, 2, 3, 1])
|
||||
})
|
||||
})
|
||||
|
||||
describe('marquee', () => {
|
||||
it('returns fixed-width slice', () => {
|
||||
expect(marquee('abc', 0, 5)).toHaveLength(5)
|
||||
expect(marquee('abc', 1, 5)).not.toEqual(marquee('abc', 0, 5))
|
||||
})
|
||||
})
|
||||
|
||||
describe('plotLineRows', () => {
|
||||
it('returns the requested height', () => {
|
||||
expect(plotLineRows([1, 2, 3, 4], 4, 3)).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('each row has the requested width', () => {
|
||||
const rows = plotLineRows([1, 4, 2, 5], 20, 4)
|
||||
|
||||
for (const row of rows) {
|
||||
expect([...row]).toHaveLength(20)
|
||||
}
|
||||
})
|
||||
|
||||
it('draws visible braille for varied data', () => {
|
||||
expect(plotLineRows([1, 4, 2, 5], 8, 3).join('')).toMatch(/[^\u2800 ]/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('livePoints', () => {
|
||||
const asset = { label: 'TEST', series: [100, 102, 101, 103, 105] }
|
||||
|
||||
it('extends the series by one', () => {
|
||||
expect(livePoints(asset, 0)).toHaveLength(asset.series.length + 1)
|
||||
})
|
||||
|
||||
it('starts at series[0]', () => {
|
||||
expect(livePoints(asset, 0)[0]).toBe(100)
|
||||
})
|
||||
|
||||
it('live point stays within ±2% of last value', () => {
|
||||
const last = asset.series.at(-1)!
|
||||
|
||||
for (let t = 0; t < 50; t++) {
|
||||
const pts = livePoints(asset, t)
|
||||
const live = pts.at(-1)!
|
||||
|
||||
expect(live).toBeGreaterThan(last * 0.98)
|
||||
expect(live).toBeLessThan(last * 1.02)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('cityTime', () => {
|
||||
it('returns HH:MM:SS format', () => {
|
||||
expect(cityTime('America/New_York')).toMatch(/^\d{2}:\d{2}:\d{2}$/)
|
||||
})
|
||||
|
||||
it('works for all clock zones', () => {
|
||||
for (const tz of ['America/New_York', 'Europe/London', 'Asia/Tokyo', 'Australia/Sydney']) {
|
||||
expect(cityTime(tz)).toMatch(/^\d{2}:\d{2}:\d{2}$/)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildWidgets', () => {
|
||||
it('routes widgets into dock + sidebar regions', () => {
|
||||
const widgets = buildWidgets({ ...BASE_CTX, blocked: true })
|
||||
const byId = new Map(widgets.map(w => [w.id, w.region]))
|
||||
|
||||
expect(byId.get('ticker')).toBe('dock')
|
||||
expect(byId.get('world-clock')).toBe('sidebar')
|
||||
expect(byId.get('weather')).toBe('sidebar')
|
||||
expect(byId.get('heartbeat')).toBe('sidebar')
|
||||
})
|
||||
|
||||
it('filters by enabled map', () => {
|
||||
const enabled = { ticker: true, 'world-clock': false, weather: true, heartbeat: false }
|
||||
const widgets = buildWidgets(BASE_CTX, { enabled })
|
||||
const ids = widgets.map(w => w.id)
|
||||
|
||||
expect(ids).toContain('ticker')
|
||||
expect(ids).toContain('weather')
|
||||
expect(ids).not.toContain('world-clock')
|
||||
expect(ids).not.toContain('heartbeat')
|
||||
})
|
||||
|
||||
it('accepts widget params config', () => {
|
||||
const widgets = buildWidgets(BASE_CTX, {
|
||||
enabled: { ticker: true, 'world-clock': true, weather: true, heartbeat: true },
|
||||
params: { ticker: { asset: 'ETH' } }
|
||||
})
|
||||
|
||||
expect(widgets.map(w => w.id)).toContain('ticker')
|
||||
})
|
||||
|
||||
it('returns all when no enabled map given', () => {
|
||||
const widgets = buildWidgets({ ...BASE_CTX, blocked: false })
|
||||
|
||||
expect(widgets.some(w => w.region === 'overlay')).toBe(false)
|
||||
expect(widgets.some(w => w.region === 'dock')).toBe(true)
|
||||
})
|
||||
|
||||
it('includes all expected widget ids', () => {
|
||||
const ids = buildWidgets(BASE_CTX).map(w => w.id)
|
||||
|
||||
expect(ids).toContain('ticker')
|
||||
expect(ids).toContain('weather')
|
||||
expect(ids).toContain('world-clock')
|
||||
expect(ids).toContain('heartbeat')
|
||||
})
|
||||
})
|
||||
|
||||
describe('bloombergTheme', () => {
|
||||
it('overrides color keys while preserving brand', () => {
|
||||
const bt = bloombergTheme(DEFAULT_THEME)
|
||||
|
||||
expect(bt.brand).toEqual(DEFAULT_THEME.brand)
|
||||
expect(bt.color.cornsilk).toBe('#FFFFFF')
|
||||
expect(bt.color.statusGood).toBe('#00EE00')
|
||||
expect(bt.color.statusBad).toBe('#FF2200')
|
||||
})
|
||||
})
|
||||
|
|
@ -7,7 +7,6 @@ import { createGatewayEventHandler } from './app/createGatewayEventHandler.js'
|
|||
import { createSlashHandler } from './app/createSlashHandler.js'
|
||||
import { GatewayProvider } from './app/gatewayContext.js'
|
||||
import {
|
||||
fmtDuration,
|
||||
imageTokenMeta,
|
||||
introMsg,
|
||||
looksLikeSlashCommand,
|
||||
|
|
@ -15,7 +14,7 @@ import {
|
|||
shortCwd,
|
||||
toTranscriptMessages
|
||||
} from './app/helpers.js'
|
||||
import { type TranscriptRow } from './app/interfaces.js'
|
||||
import { type GatewayRpc, type TranscriptRow } from './app/interfaces.js'
|
||||
import { $isBlocked, $overlayState, patchOverlayState } from './app/overlayStore.js'
|
||||
import { $uiState, getUiState, patchUiState } from './app/uiStore.js'
|
||||
import { useComposerState } from './app/useComposerState.js'
|
||||
|
|
@ -23,7 +22,8 @@ import { useInputHandlers } from './app/useInputHandlers.js'
|
|||
import { useTurnState } from './app/useTurnState.js'
|
||||
import { AppLayout } from './components/appLayout.js'
|
||||
import { INTERPOLATION_RE, ZERO } from './constants.js'
|
||||
import { type GatewayClient, type GatewayEvent } from './gatewayClient.js'
|
||||
import { type GatewayClient } from './gatewayClient.js'
|
||||
import type { ConfigFullResponse, ConfigMtimeResponse, GatewayEvent, SessionCreateResponse } from './gatewayTypes.js'
|
||||
import { useVirtualHistory } from './hooks/useVirtualHistory.js'
|
||||
import { asRpcResult, rpcErrorMessage } from './lib/rpc.js'
|
||||
import { buildToolTrailLine, hasInterpolation, sameToolTrailGroup, toolTrailLabel } from './lib/text.js'
|
||||
|
|
@ -60,7 +60,6 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
|
||||
// ── State ────────────────────────────────────────────────────────
|
||||
|
||||
const [messages, setMessages] = useState<Msg[]>([])
|
||||
const [historyItems, setHistoryItems] = useState<Msg[]>([])
|
||||
const [lastUserMsg, setLastUserMsg] = useState('')
|
||||
const [stickyPrompt, setStickyPrompt] = useState('')
|
||||
|
|
@ -70,7 +69,6 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
const [voiceProcessing, setVoiceProcessing] = useState(false)
|
||||
const [sessionStartedAt, setSessionStartedAt] = useState(() => Date.now())
|
||||
const [bellOnComplete, setBellOnComplete] = useState(false)
|
||||
const [clockNow, setClockNow] = useState(() => Date.now())
|
||||
const ui = useStore($uiState)
|
||||
const overlay = useStore($overlayState)
|
||||
const isBlocked = useStore($isBlocked)
|
||||
|
|
@ -85,7 +83,13 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
const clipboardPasteRef = useRef<(quiet?: boolean) => Promise<void> | void>(() => {})
|
||||
const submitRef = useRef<(value: string) => void>(() => {})
|
||||
const configMtimeRef = useRef(0)
|
||||
const historyItemsRef = useRef(historyItems)
|
||||
const lastUserMsgRef = useRef(lastUserMsg)
|
||||
const msgIdsRef = useRef(new WeakMap<Msg, string>())
|
||||
const nextMsgIdRef = useRef(0)
|
||||
colsRef.current = cols
|
||||
historyItemsRef.current = historyItems
|
||||
lastUserMsgRef.current = lastUserMsg
|
||||
|
||||
// ── Hooks ────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -105,17 +109,36 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
const composerActions = composer.actions
|
||||
const composerRefs = composer.refs
|
||||
const composerState = composer.state
|
||||
const composerCompletions = composerState.completions
|
||||
const composerCompIdx = composerState.compIdx
|
||||
const composerInput = composerState.input
|
||||
const composerInputBuf = composerState.inputBuf
|
||||
const composerQueueEditIdx = composerState.queueEditIdx
|
||||
const composerQueuedDisplay = composerState.queuedDisplay
|
||||
|
||||
const empty = !messages.length
|
||||
const empty = !historyItems.some(msg => msg.kind !== 'intro')
|
||||
|
||||
const messageId = useCallback((msg: Msg) => {
|
||||
const hit = msgIdsRef.current.get(msg)
|
||||
|
||||
if (hit) {
|
||||
return hit
|
||||
}
|
||||
|
||||
const next = `m${++nextMsgIdRef.current}`
|
||||
msgIdsRef.current.set(msg, next)
|
||||
|
||||
return next
|
||||
}, [])
|
||||
|
||||
const virtualRows = useMemo<TranscriptRow[]>(
|
||||
() =>
|
||||
historyItems.map((msg, index) => ({
|
||||
index,
|
||||
key: `${index}:${msg.role}:${msg.kind ?? ''}:${msg.text.slice(0, 40)}`,
|
||||
key: messageId(msg),
|
||||
msg
|
||||
})),
|
||||
[historyItems]
|
||||
[historyItems, messageId]
|
||||
)
|
||||
|
||||
const virtualHistory = useVirtualHistory(scrollRef, virtualRows)
|
||||
|
|
@ -173,12 +196,6 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
[selection]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setClockNow(Date.now()), 1000)
|
||||
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
// ── Core actions ─────────────────────────────────────────────────
|
||||
|
||||
const appendMessage = useCallback((msg: Msg) => {
|
||||
|
|
@ -189,7 +206,6 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
? [items[0]!, ...items.slice(-(MAX_HISTORY - 1))]
|
||||
: items.slice(-MAX_HISTORY)
|
||||
|
||||
setMessages(prev => cap([...prev, msg]))
|
||||
setHistoryItems(prev => cap([...prev, msg]))
|
||||
}, [])
|
||||
|
||||
|
|
@ -220,10 +236,24 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
const pruneTransient = turnActions.pruneTransient
|
||||
const pushTrail = turnActions.pushTrail
|
||||
|
||||
const rpc = useCallback(
|
||||
async (method: string, params: Record<string, unknown> = {}) => {
|
||||
const applyDisplayConfig = useCallback((cfg: ConfigFullResponse | null) => {
|
||||
const display = cfg?.config?.display ?? {}
|
||||
|
||||
setBellOnComplete(!!display?.bell_on_complete)
|
||||
patchUiState({
|
||||
compact: !!display?.tui_compact,
|
||||
detailsMode: resolveDetailsMode(display),
|
||||
statusBar: display?.tui_statusbar !== false
|
||||
})
|
||||
}, [])
|
||||
|
||||
const rpc: GatewayRpc = useCallback(
|
||||
async <T extends Record<string, any> = Record<string, any>>(
|
||||
method: string,
|
||||
params: Record<string, unknown> = {}
|
||||
) => {
|
||||
try {
|
||||
const result = asRpcResult(await gw.request(method, params))
|
||||
const result = asRpcResult<T>(await gw.request<T>(method, params))
|
||||
|
||||
if (result) {
|
||||
return result
|
||||
|
|
@ -301,20 +331,11 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
}
|
||||
|
||||
rpc('voice.toggle', { action: 'status' }).then((r: any) => setVoiceEnabled(!!r?.enabled))
|
||||
rpc('config.get', { key: 'mtime' }).then((r: any) => {
|
||||
rpc<ConfigMtimeResponse>('config.get', { key: 'mtime' }).then(r => {
|
||||
configMtimeRef.current = Number(r?.mtime ?? 0)
|
||||
})
|
||||
rpc('config.get', { key: 'full' }).then((r: any) => {
|
||||
const display = r?.config?.display ?? {}
|
||||
|
||||
setBellOnComplete(!!display?.bell_on_complete)
|
||||
patchUiState({
|
||||
compact: !!display?.tui_compact,
|
||||
detailsMode: resolveDetailsMode(display),
|
||||
statusBar: display?.tui_statusbar !== false
|
||||
})
|
||||
})
|
||||
}, [rpc, ui.sid])
|
||||
rpc<ConfigFullResponse>('config.get', { key: 'full' }).then(applyDisplayConfig)
|
||||
}, [applyDisplayConfig, rpc, ui.sid])
|
||||
|
||||
useEffect(() => {
|
||||
if (!ui.sid) {
|
||||
|
|
@ -322,7 +343,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
}
|
||||
|
||||
const id = setInterval(() => {
|
||||
rpc('config.get', { key: 'mtime' }).then((r: any) => {
|
||||
rpc<ConfigMtimeResponse>('config.get', { key: 'mtime' }).then(r => {
|
||||
const next = Number(r?.mtime ?? 0)
|
||||
|
||||
if (configMtimeRef.current && next && next !== configMtimeRef.current) {
|
||||
|
|
@ -334,16 +355,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
|
||||
pushActivity('MCP reloaded after config change')
|
||||
})
|
||||
rpc('config.get', { key: 'full' }).then((cfg: any) => {
|
||||
const display = cfg?.config?.display ?? {}
|
||||
|
||||
setBellOnComplete(!!display?.bell_on_complete)
|
||||
patchUiState({
|
||||
compact: !!display?.tui_compact,
|
||||
detailsMode: resolveDetailsMode(display),
|
||||
statusBar: display?.tui_statusbar !== false
|
||||
})
|
||||
})
|
||||
rpc<ConfigFullResponse>('config.get', { key: 'full' }).then(applyDisplayConfig)
|
||||
} else if (!configMtimeRef.current && next) {
|
||||
configMtimeRef.current = next
|
||||
}
|
||||
|
|
@ -351,7 +363,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
}, 5000)
|
||||
|
||||
return () => clearInterval(id)
|
||||
}, [pushActivity, rpc, ui.sid])
|
||||
}, [applyDisplayConfig, pushActivity, rpc, ui.sid])
|
||||
|
||||
const idle = turnActions.idle
|
||||
const clearReasoning = turnActions.clearReasoning
|
||||
|
|
@ -373,7 +385,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
usage: ZERO
|
||||
})
|
||||
setHistoryItems([])
|
||||
setMessages([])
|
||||
setLastUserMsg('')
|
||||
setStickyPrompt('')
|
||||
composerActions.setPasteSnips([])
|
||||
turnActions.setActivity([])
|
||||
|
|
@ -387,7 +399,6 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
(info: SessionInfo | null = null) => {
|
||||
idle()
|
||||
clearReasoning()
|
||||
setMessages([])
|
||||
setHistoryItems(info ? [introMsg(info)] : [])
|
||||
patchUiState({
|
||||
info,
|
||||
|
|
@ -403,7 +414,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
[clearReasoning, composerActions, idle, turnActions, turnRefs]
|
||||
)
|
||||
|
||||
const trimLastExchange = (items: Msg[]) => {
|
||||
const trimLastExchange = useCallback((items: Msg[]) => {
|
||||
const q = [...items]
|
||||
|
||||
while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') {
|
||||
|
|
@ -415,7 +426,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
}
|
||||
|
||||
return q
|
||||
}
|
||||
}, [])
|
||||
|
||||
const guardBusySessionSwitch = useCallback(
|
||||
(what = 'switch sessions') => {
|
||||
|
|
@ -447,7 +458,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
async (msg?: string) => {
|
||||
await closeSession(getUiState().sid)
|
||||
|
||||
return rpc('session.create', { cols: colsRef.current }).then((r: any) => {
|
||||
return rpc<SessionCreateResponse>('session.create', { cols: colsRef.current }).then(r => {
|
||||
if (!r) {
|
||||
patchUiState({ status: 'ready' })
|
||||
|
||||
|
|
@ -500,7 +511,6 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
setSessionStartedAt(Date.now())
|
||||
const resumed = toTranscriptMessages(r.messages)
|
||||
|
||||
setMessages(resumed)
|
||||
setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed)
|
||||
patchUiState({
|
||||
info: r.info ?? null,
|
||||
|
|
@ -833,8 +843,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
},
|
||||
transcript: {
|
||||
appendMessage,
|
||||
setHistoryItems,
|
||||
setMessages
|
||||
setHistoryItems
|
||||
},
|
||||
turn: {
|
||||
actions: {
|
||||
|
|
@ -850,6 +859,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
setActivity: turnActions.setActivity,
|
||||
setReasoningTokens: turnActions.setReasoningTokens,
|
||||
setStreaming: turnActions.setStreaming,
|
||||
setSubagents: turnActions.setSubagents,
|
||||
setToolTokens: turnActions.setToolTokens,
|
||||
setTools: turnActions.setTools,
|
||||
setTurnTrail: turnActions.setTurnTrail
|
||||
|
|
@ -913,46 +923,73 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
}, [gw, pushActivity, sys])
|
||||
|
||||
// ── Slash commands ───────────────────────────────────────────────
|
||||
// Always current via ref — no useMemo deps duplication needed.
|
||||
|
||||
slashRef.current = createSlashHandler({
|
||||
composer: {
|
||||
enqueue: composerActions.enqueue,
|
||||
hasSelection,
|
||||
paste,
|
||||
queueRef: composerRefs.queueRef,
|
||||
selection,
|
||||
setInput: composerActions.setInput
|
||||
},
|
||||
gateway,
|
||||
local: {
|
||||
const slash = useMemo(
|
||||
() =>
|
||||
createSlashHandler({
|
||||
composer: {
|
||||
enqueue: composerActions.enqueue,
|
||||
hasSelection,
|
||||
paste,
|
||||
queueRef: composerRefs.queueRef,
|
||||
selection,
|
||||
setInput: composerActions.setInput
|
||||
},
|
||||
gateway,
|
||||
local: {
|
||||
catalog,
|
||||
getHistoryItems: () => historyItemsRef.current,
|
||||
getLastUserMsg: () => lastUserMsgRef.current,
|
||||
maybeWarn
|
||||
},
|
||||
session: {
|
||||
closeSession,
|
||||
die,
|
||||
guardBusySessionSwitch,
|
||||
newSession,
|
||||
resetVisibleHistory,
|
||||
resumeById,
|
||||
setSessionStartedAt
|
||||
},
|
||||
transcript: {
|
||||
page,
|
||||
panel,
|
||||
send,
|
||||
setHistoryItems,
|
||||
sys,
|
||||
trimLastExchange
|
||||
},
|
||||
voice: {
|
||||
setVoiceEnabled
|
||||
}
|
||||
}),
|
||||
[
|
||||
catalog,
|
||||
lastUserMsg,
|
||||
maybeWarn,
|
||||
messages
|
||||
},
|
||||
session: {
|
||||
closeSession,
|
||||
composerActions,
|
||||
composerRefs,
|
||||
die,
|
||||
gateway,
|
||||
guardBusySessionSwitch,
|
||||
hasSelection,
|
||||
maybeWarn,
|
||||
newSession,
|
||||
resetVisibleHistory,
|
||||
resumeById,
|
||||
setSessionStartedAt
|
||||
},
|
||||
transcript: {
|
||||
page,
|
||||
panel,
|
||||
paste,
|
||||
resetVisibleHistory,
|
||||
resumeById,
|
||||
selection,
|
||||
send,
|
||||
setSessionStartedAt,
|
||||
setHistoryItems,
|
||||
setMessages,
|
||||
setVoiceEnabled,
|
||||
sys,
|
||||
trimLastExchange
|
||||
},
|
||||
voice: {
|
||||
setVoiceEnabled
|
||||
}
|
||||
})
|
||||
]
|
||||
)
|
||||
|
||||
slashRef.current = slash
|
||||
|
||||
// ── Submit ───────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -1033,7 +1070,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
? ui.theme.color.warn
|
||||
: ui.theme.color.dim
|
||||
|
||||
const durationLabel = ui.sid ? fmtDuration(clockNow - sessionStartedAt) : ''
|
||||
const sessionStarted = ui.sid ? sessionStartedAt : null
|
||||
const voiceLabel = voiceRecording ? 'REC' : voiceProcessing ? 'STT' : `voice ${voiceEnabled ? 'on' : 'off'}`
|
||||
const cwdLabel = shortCwd(ui.info?.cwd || process.env.HERMES_CWD || process.cwd())
|
||||
const showStreamingArea = Boolean(turnState.streaming)
|
||||
|
|
@ -1045,7 +1082,12 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
ui.detailsMode === 'hidden'
|
||||
? turnState.activity.some(item => item.tone !== 'info')
|
||||
: Boolean(
|
||||
ui.busy || turnState.tools.length || turnState.turnTrail.length || hasReasoning || turnState.activity.length
|
||||
ui.busy ||
|
||||
turnState.subagents.length ||
|
||||
turnState.tools.length ||
|
||||
turnState.turnTrail.length ||
|
||||
hasReasoning ||
|
||||
turnState.activity.length
|
||||
)
|
||||
|
||||
const answerApproval = useCallback(
|
||||
|
|
@ -1104,62 +1146,101 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
slashRef.current(`/model ${value}`)
|
||||
}, [])
|
||||
|
||||
const appActions = useMemo(
|
||||
() => ({
|
||||
answerApproval,
|
||||
answerClarify,
|
||||
answerSecret,
|
||||
answerSudo,
|
||||
onModelSelect,
|
||||
resumeById,
|
||||
setStickyPrompt
|
||||
}),
|
||||
[answerApproval, answerClarify, answerSecret, answerSudo, onModelSelect, resumeById, setStickyPrompt]
|
||||
)
|
||||
|
||||
const appComposer = useMemo(
|
||||
() => ({
|
||||
cols,
|
||||
compIdx: composerCompIdx,
|
||||
completions: composerCompletions,
|
||||
empty,
|
||||
handleTextPaste,
|
||||
input: composerInput,
|
||||
inputBuf: composerInputBuf,
|
||||
pagerPageSize,
|
||||
queueEditIdx: composerQueueEditIdx,
|
||||
queuedDisplay: composerQueuedDisplay,
|
||||
submit,
|
||||
updateInput: composerActions.setInput
|
||||
}),
|
||||
[
|
||||
cols,
|
||||
composerActions.setInput,
|
||||
composerCompIdx,
|
||||
composerCompletions,
|
||||
composerInput,
|
||||
composerInputBuf,
|
||||
composerQueueEditIdx,
|
||||
composerQueuedDisplay,
|
||||
empty,
|
||||
handleTextPaste,
|
||||
pagerPageSize,
|
||||
submit
|
||||
]
|
||||
)
|
||||
|
||||
const appProgress = useMemo(
|
||||
() => ({
|
||||
activity: turnState.activity,
|
||||
reasoning: turnState.reasoning,
|
||||
reasoningActive: turnState.reasoningActive,
|
||||
reasoningStreaming: turnState.reasoningStreaming,
|
||||
reasoningTokens: turnState.reasoningTokens,
|
||||
showProgressArea,
|
||||
showStreamingArea,
|
||||
streaming: turnState.streaming,
|
||||
subagents: turnState.subagents,
|
||||
toolTokens: turnState.toolTokens,
|
||||
tools: turnState.tools,
|
||||
turnTrail: turnState.turnTrail
|
||||
}),
|
||||
[showProgressArea, showStreamingArea, turnState]
|
||||
)
|
||||
|
||||
const appStatus = useMemo(
|
||||
() => ({
|
||||
cwdLabel,
|
||||
sessionStartedAt: sessionStarted,
|
||||
showStickyPrompt,
|
||||
statusColor,
|
||||
stickyPrompt,
|
||||
voiceLabel
|
||||
}),
|
||||
[cwdLabel, sessionStarted, showStickyPrompt, statusColor, stickyPrompt, voiceLabel]
|
||||
)
|
||||
|
||||
const appTranscript = useMemo(
|
||||
() => ({
|
||||
historyItems,
|
||||
scrollRef,
|
||||
virtualHistory,
|
||||
virtualRows
|
||||
}),
|
||||
[historyItems, scrollRef, virtualHistory, virtualRows]
|
||||
)
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<GatewayProvider value={gateway}>
|
||||
<AppLayout
|
||||
actions={{
|
||||
answerApproval,
|
||||
answerClarify,
|
||||
answerSecret,
|
||||
answerSudo,
|
||||
onModelSelect,
|
||||
resumeById,
|
||||
setStickyPrompt
|
||||
}}
|
||||
composer={{
|
||||
cols,
|
||||
compIdx: composerState.compIdx,
|
||||
completions: composerState.completions,
|
||||
empty,
|
||||
handleTextPaste,
|
||||
input: composerState.input,
|
||||
inputBuf: composerState.inputBuf,
|
||||
pagerPageSize,
|
||||
queueEditIdx: composerState.queueEditIdx,
|
||||
queuedDisplay: composerState.queuedDisplay,
|
||||
submit,
|
||||
updateInput: composerActions.setInput
|
||||
}}
|
||||
actions={appActions}
|
||||
composer={appComposer}
|
||||
mouseTracking={MOUSE_TRACKING}
|
||||
progress={{
|
||||
activity: turnState.activity,
|
||||
reasoning: turnState.reasoning,
|
||||
reasoningTokens: turnState.reasoningTokens,
|
||||
reasoningActive: turnState.reasoningActive,
|
||||
reasoningStreaming: turnState.reasoningStreaming,
|
||||
showProgressArea,
|
||||
showStreamingArea,
|
||||
streaming: turnState.streaming,
|
||||
toolTokens: turnState.toolTokens,
|
||||
tools: turnState.tools,
|
||||
turnTrail: turnState.turnTrail
|
||||
}}
|
||||
status={{
|
||||
cwdLabel,
|
||||
durationLabel,
|
||||
showStickyPrompt,
|
||||
statusColor,
|
||||
stickyPrompt,
|
||||
voiceLabel
|
||||
}}
|
||||
transcript={{
|
||||
historyItems,
|
||||
scrollRef,
|
||||
virtualHistory,
|
||||
virtualRows
|
||||
}}
|
||||
progress={appProgress}
|
||||
status={appStatus}
|
||||
transcript={appTranscript}
|
||||
/>
|
||||
</GatewayProvider>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
import type { GatewayEvent } from '../gatewayClient.js'
|
||||
import type { CommandsCatalogResponse, GatewayEvent, SessionResumeResponse } from '../gatewayTypes.js'
|
||||
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
|
||||
import {
|
||||
buildToolTrailLine,
|
||||
estimateTokensRough,
|
||||
formatToolCall,
|
||||
isToolTrailResultLine,
|
||||
sameToolTrailGroup,
|
||||
toolTrailLabel
|
||||
} from '../lib/text.js'
|
||||
import { fromSkin } from '../theme.js'
|
||||
|
||||
import { STREAM_BATCH_MS } from './constants.js'
|
||||
import { introMsg, toTranscriptMessages } from './helpers.js'
|
||||
import type { GatewayEventHandlerContext } from './interfaces.js'
|
||||
import { patchOverlayState } from './overlayStore.js'
|
||||
|
|
@ -19,7 +21,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
const { gw, rpc } = ctx.gateway
|
||||
const { STARTUP_RESUME_ID, colsRef, newSession, resetSession, setCatalog } = ctx.session
|
||||
const { bellOnComplete, stdout, sys } = ctx.system
|
||||
const { appendMessage, setHistoryItems, setMessages } = ctx.transcript
|
||||
const { appendMessage, setHistoryItems } = ctx.transcript
|
||||
|
||||
const {
|
||||
clearReasoning,
|
||||
|
|
@ -32,8 +34,8 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
scheduleReasoning,
|
||||
scheduleStreaming,
|
||||
setActivity,
|
||||
setReasoningTokens,
|
||||
setStreaming,
|
||||
setSubagents,
|
||||
setToolTokens,
|
||||
setTools,
|
||||
setTurnTrail
|
||||
|
|
@ -53,6 +55,108 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
turnToolsRef
|
||||
} = ctx.turn.refs
|
||||
|
||||
let pendingThinkingStatus = ''
|
||||
let thinkingStatusTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let toolProgressTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const cancelThinkingStatus = () => {
|
||||
pendingThinkingStatus = ''
|
||||
|
||||
if (thinkingStatusTimer) {
|
||||
clearTimeout(thinkingStatusTimer)
|
||||
thinkingStatusTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const setStatus = (status: string) => {
|
||||
cancelThinkingStatus()
|
||||
patchUiState({ status })
|
||||
}
|
||||
|
||||
const scheduleThinkingStatus = (status: string) => {
|
||||
pendingThinkingStatus = status
|
||||
|
||||
if (thinkingStatusTimer) {
|
||||
return
|
||||
}
|
||||
|
||||
thinkingStatusTimer = setTimeout(() => {
|
||||
thinkingStatusTimer = null
|
||||
patchUiState({ status: pendingThinkingStatus || (getUiState().busy ? 'running…' : 'ready') })
|
||||
}, STREAM_BATCH_MS)
|
||||
}
|
||||
|
||||
const scheduleToolProgress = () => {
|
||||
if (toolProgressTimer) {
|
||||
return
|
||||
}
|
||||
|
||||
toolProgressTimer = setTimeout(() => {
|
||||
toolProgressTimer = null
|
||||
setTools([...activeToolsRef.current])
|
||||
}, STREAM_BATCH_MS)
|
||||
}
|
||||
|
||||
const upsertSubagent = (
|
||||
taskIndex: number,
|
||||
taskCount: number,
|
||||
goal: string,
|
||||
update: (current: {
|
||||
durationSeconds?: number
|
||||
goal: string
|
||||
id: string
|
||||
index: number
|
||||
notes: string[]
|
||||
status: 'completed' | 'failed' | 'interrupted' | 'running'
|
||||
summary?: string
|
||||
taskCount: number
|
||||
thinking: string[]
|
||||
tools: string[]
|
||||
}) => {
|
||||
durationSeconds?: number
|
||||
goal: string
|
||||
id: string
|
||||
index: number
|
||||
notes: string[]
|
||||
status: 'completed' | 'failed' | 'interrupted' | 'running'
|
||||
summary?: string
|
||||
taskCount: number
|
||||
thinking: string[]
|
||||
tools: string[]
|
||||
}
|
||||
) => {
|
||||
const id = `sa:${taskIndex}:${goal || 'subagent'}`
|
||||
|
||||
setSubagents(prev => {
|
||||
const index = prev.findIndex(item => item.id === id)
|
||||
|
||||
const base =
|
||||
index >= 0
|
||||
? prev[index]!
|
||||
: {
|
||||
id,
|
||||
index: taskIndex,
|
||||
taskCount,
|
||||
goal,
|
||||
notes: [],
|
||||
status: 'running' as const,
|
||||
thinking: [],
|
||||
tools: []
|
||||
}
|
||||
|
||||
const nextItem = update(base)
|
||||
|
||||
if (index < 0) {
|
||||
return [...prev, nextItem].sort((a, b) => a.index - b.index)
|
||||
}
|
||||
|
||||
const next = [...prev]
|
||||
next[index] = nextItem
|
||||
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (ev: GatewayEvent) => {
|
||||
const sid = getUiState().sid
|
||||
|
||||
|
|
@ -60,10 +164,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
return
|
||||
}
|
||||
|
||||
const p = ev.payload as any
|
||||
|
||||
switch (ev.type) {
|
||||
case 'gateway.ready':
|
||||
case 'gateway.ready': {
|
||||
const p = ev.payload
|
||||
|
||||
if (p?.skin) {
|
||||
patchUiState({
|
||||
theme: fromSkin(
|
||||
|
|
@ -75,15 +179,15 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
})
|
||||
}
|
||||
|
||||
rpc('commands.catalog', {})
|
||||
.then((r: any) => {
|
||||
rpc<CommandsCatalogResponse>('commands.catalog', {})
|
||||
.then(r => {
|
||||
if (!r?.pairs) {
|
||||
return
|
||||
}
|
||||
|
||||
setCatalog({
|
||||
canon: (r.canon ?? {}) as Record<string, string>,
|
||||
categories: (r.categories ?? []) as any,
|
||||
categories: r.categories ?? [],
|
||||
pairs: r.pairs as [string, string][],
|
||||
skillCount: (r.skill_count ?? 0) as number,
|
||||
sub: (r.sub ?? {}) as Record<string, string[]>
|
||||
|
|
@ -97,9 +201,9 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
|
||||
if (STARTUP_RESUME_ID) {
|
||||
patchUiState({ status: 'resuming…' })
|
||||
gw.request('session.resume', { cols: colsRef.current, session_id: STARTUP_RESUME_ID })
|
||||
.then((raw: any) => {
|
||||
const r = asRpcResult(raw)
|
||||
gw.request<SessionResumeResponse>('session.resume', { cols: colsRef.current, session_id: STARTUP_RESUME_ID })
|
||||
.then(raw => {
|
||||
const r = asRpcResult<SessionResumeResponse>(raw)
|
||||
|
||||
if (!r) {
|
||||
throw new Error('invalid response: session.resume')
|
||||
|
|
@ -114,7 +218,6 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
status: 'ready',
|
||||
usage: r.info?.usage ?? getUiState().usage
|
||||
})
|
||||
setMessages(resumed)
|
||||
setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
|
|
@ -128,8 +231,11 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'skin.changed': {
|
||||
const p = ev.payload
|
||||
|
||||
case 'skin.changed':
|
||||
if (p) {
|
||||
patchUiState({
|
||||
theme: fromSkin(p.colors ?? {}, p.branding ?? {}, p.banner_logo ?? '', p.banner_hero ?? '')
|
||||
|
|
@ -137,28 +243,36 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'session.info': {
|
||||
const p = ev.payload
|
||||
|
||||
case 'session.info':
|
||||
patchUiState(state => ({
|
||||
...state,
|
||||
info: p as any,
|
||||
usage: p?.usage ? { ...state.usage, ...p.usage } : state.usage
|
||||
info: p,
|
||||
usage: p.usage ? { ...state.usage, ...p.usage } : state.usage
|
||||
}))
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'thinking.delta': {
|
||||
const p = ev.payload
|
||||
|
||||
case 'thinking.delta':
|
||||
if (p && Object.prototype.hasOwnProperty.call(p, 'text')) {
|
||||
patchUiState({ status: p.text ? String(p.text) : getUiState().busy ? 'running…' : 'ready' })
|
||||
scheduleThinkingStatus(p.text ? String(p.text) : getUiState().busy ? 'running…' : 'ready')
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'message.start':
|
||||
patchUiState({ busy: true })
|
||||
endReasoningPhase()
|
||||
clearReasoning()
|
||||
setActivity([])
|
||||
setSubagents([])
|
||||
setTurnTrail([])
|
||||
activeToolsRef.current = []
|
||||
setTools([])
|
||||
|
|
@ -168,10 +282,11 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
setToolTokens(0)
|
||||
|
||||
break
|
||||
case 'status.update': {
|
||||
const p = ev.payload
|
||||
|
||||
case 'status.update':
|
||||
if (p?.text) {
|
||||
patchUiState({ status: p.text })
|
||||
setStatus(p.text)
|
||||
|
||||
if (p.kind && p.kind !== 'status') {
|
||||
if (lastStatusNoteRef.current !== p.text) {
|
||||
|
|
@ -194,8 +309,11 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'gateway.stderr': {
|
||||
const p = ev.payload
|
||||
|
||||
case 'gateway.stderr':
|
||||
if (p?.line) {
|
||||
const line = String(p.line).slice(0, 120)
|
||||
const tone = /\b(error|traceback|exception|failed|spawn)\b/i.test(line) ? 'error' : 'warn'
|
||||
|
|
@ -204,18 +322,24 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'gateway.start_timeout':
|
||||
patchUiState({ status: 'gateway startup timeout' })
|
||||
case 'gateway.start_timeout': {
|
||||
const p = ev.payload
|
||||
|
||||
setStatus('gateway startup timeout')
|
||||
pushActivity(
|
||||
`gateway startup timed out${p?.python || p?.cwd ? ` · ${String(p?.python || '')} ${String(p?.cwd || '')}`.trim() : ''} · /logs to inspect`,
|
||||
'error'
|
||||
)
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'gateway.protocol_error':
|
||||
patchUiState({ status: 'protocol warning' })
|
||||
case 'gateway.protocol_error': {
|
||||
const p = ev.payload
|
||||
|
||||
setStatus('protocol warning')
|
||||
|
||||
if (statusTimerRef.current) {
|
||||
clearTimeout(statusTimerRef.current)
|
||||
|
|
@ -236,17 +360,22 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'reasoning.delta': {
|
||||
const p = ev.payload
|
||||
|
||||
case 'reasoning.delta':
|
||||
if (p?.text) {
|
||||
reasoningRef.current += p.text
|
||||
setReasoningTokens(estimateTokensRough(reasoningRef.current))
|
||||
scheduleReasoning()
|
||||
pulseReasoningStreaming()
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'reasoning.available': {
|
||||
const p = ev.payload
|
||||
const incoming = String(p?.text ?? '').trim()
|
||||
|
||||
if (!incoming) {
|
||||
|
|
@ -261,7 +390,6 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
// nothing.
|
||||
if (!current) {
|
||||
reasoningRef.current = incoming
|
||||
setReasoningTokens(estimateTokensRough(reasoningRef.current))
|
||||
scheduleReasoning()
|
||||
pulseReasoningStreaming()
|
||||
}
|
||||
|
|
@ -269,7 +397,9 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
break
|
||||
}
|
||||
|
||||
case 'tool.progress':
|
||||
case 'tool.progress': {
|
||||
const p = ev.payload
|
||||
|
||||
if (p?.preview) {
|
||||
const index = activeToolsRef.current.findIndex(tool => tool.name === p.name)
|
||||
|
||||
|
|
@ -278,28 +408,35 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
|
||||
next[index] = { ...next[index]!, context: p.preview as string }
|
||||
activeToolsRef.current = next
|
||||
setTools(next)
|
||||
scheduleToolProgress()
|
||||
}
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'tool.generating': {
|
||||
const p = ev.payload
|
||||
|
||||
case 'tool.generating':
|
||||
if (p?.name) {
|
||||
pushTrail(`drafting ${p.name}…`)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'tool.start': {
|
||||
const p = ev.payload
|
||||
pruneTransient()
|
||||
endReasoningPhase()
|
||||
const ctx = (p.context as string) || ''
|
||||
const name = p.name ?? 'tool'
|
||||
const ctx = p.context ?? ''
|
||||
const sample = `${String(p.name ?? '')} ${ctx}`.trim()
|
||||
toolTokenAccRef.current += sample ? estimateTokensRough(sample) : 0
|
||||
setToolTokens(toolTokenAccRef.current)
|
||||
activeToolsRef.current = [
|
||||
...activeToolsRef.current,
|
||||
{ id: p.tool_id, name: p.name, context: ctx, startedAt: Date.now() }
|
||||
{ id: p.tool_id, name, context: ctx, startedAt: Date.now() }
|
||||
]
|
||||
setTools(activeToolsRef.current)
|
||||
|
||||
|
|
@ -307,17 +444,13 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
}
|
||||
|
||||
case 'tool.complete': {
|
||||
const p = ev.payload
|
||||
toolCompleteRibbonRef.current = null
|
||||
const done = activeToolsRef.current.find(tool => tool.id === p.tool_id)
|
||||
const name = done?.name ?? p.name
|
||||
const name = done?.name ?? p.name ?? 'tool'
|
||||
const label = toolTrailLabel(name)
|
||||
|
||||
const line = buildToolTrailLine(
|
||||
name,
|
||||
done?.context || '',
|
||||
!!p.error,
|
||||
(p.error as string) || (p.summary as string) || ''
|
||||
)
|
||||
const line = buildToolTrailLine(name, done?.context || '', !!p.error, p.error || p.summary || '')
|
||||
|
||||
const next = [...turnToolsRef.current.filter(item => !sameToolTrailGroup(label, item)), line]
|
||||
|
||||
|
|
@ -333,37 +466,46 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
setTurnTrail(turnToolsRef.current)
|
||||
|
||||
if (p?.inline_diff) {
|
||||
sys(p.inline_diff as string)
|
||||
sys(p.inline_diff)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'clarify.request':
|
||||
case 'clarify.request': {
|
||||
const p = ev.payload
|
||||
patchOverlayState({ clarify: { choices: p.choices, question: p.question, requestId: p.request_id } })
|
||||
patchUiState({ status: 'waiting for input…' })
|
||||
setStatus('waiting for input…')
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'approval.request':
|
||||
case 'approval.request': {
|
||||
const p = ev.payload
|
||||
patchOverlayState({ approval: { command: p.command, description: p.description } })
|
||||
patchUiState({ status: 'approval needed' })
|
||||
setStatus('approval needed')
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'sudo.request':
|
||||
case 'sudo.request': {
|
||||
const p = ev.payload
|
||||
patchOverlayState({ sudo: { requestId: p.request_id } })
|
||||
patchUiState({ status: 'sudo password needed' })
|
||||
setStatus('sudo password needed')
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'secret.request':
|
||||
case 'secret.request': {
|
||||
const p = ev.payload
|
||||
patchOverlayState({ secret: { envVar: p.env_var, prompt: p.prompt, requestId: p.request_id } })
|
||||
patchUiState({ status: 'secret input needed' })
|
||||
setStatus('secret input needed')
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'background.complete':
|
||||
case 'background.complete': {
|
||||
const p = ev.payload
|
||||
patchUiState(state => {
|
||||
const next = new Set(state.bgTasks)
|
||||
|
||||
|
|
@ -374,8 +516,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
sys(`[bg ${p.task_id}] ${p.text}`)
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'btw.complete':
|
||||
case 'btw.complete': {
|
||||
const p = ev.payload
|
||||
patchUiState(state => {
|
||||
const next = new Set(state.bgTasks)
|
||||
|
||||
|
|
@ -386,8 +530,92 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
sys(`[btw] ${p.text}`)
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'message.delta':
|
||||
case 'subagent.start': {
|
||||
const p = ev.payload
|
||||
|
||||
upsertSubagent(p.task_index, p.task_count ?? 1, p.goal, current => ({
|
||||
...current,
|
||||
goal: p.goal || current.goal,
|
||||
status: 'running',
|
||||
taskCount: p.task_count ?? current.taskCount
|
||||
}))
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'subagent.thinking': {
|
||||
const p = ev.payload
|
||||
const text = String(p.text ?? '').trim()
|
||||
|
||||
if (!text) {
|
||||
break
|
||||
}
|
||||
|
||||
upsertSubagent(p.task_index, p.task_count ?? 1, p.goal, current => ({
|
||||
...current,
|
||||
goal: p.goal || current.goal,
|
||||
status: current.status === 'completed' ? current.status : 'running',
|
||||
taskCount: p.task_count ?? current.taskCount,
|
||||
thinking: current.thinking.at(-1) === text ? current.thinking : [...current.thinking, text].slice(-6)
|
||||
}))
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'subagent.tool': {
|
||||
const p = ev.payload
|
||||
const line = formatToolCall(p.tool_name ?? 'delegate_task', p.tool_preview ?? p.text ?? '')
|
||||
|
||||
upsertSubagent(p.task_index, p.task_count ?? 1, p.goal, current => ({
|
||||
...current,
|
||||
goal: p.goal || current.goal,
|
||||
status: current.status === 'completed' ? current.status : 'running',
|
||||
taskCount: p.task_count ?? current.taskCount,
|
||||
tools: current.tools.at(-1) === line ? current.tools : [...current.tools, line].slice(-8)
|
||||
}))
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'subagent.progress': {
|
||||
const p = ev.payload
|
||||
const text = String(p.text ?? '').trim()
|
||||
|
||||
if (!text) {
|
||||
break
|
||||
}
|
||||
|
||||
upsertSubagent(p.task_index, p.task_count ?? 1, p.goal, current => ({
|
||||
...current,
|
||||
goal: p.goal || current.goal,
|
||||
status: current.status === 'completed' ? current.status : 'running',
|
||||
taskCount: p.task_count ?? current.taskCount,
|
||||
notes: current.notes.at(-1) === text ? current.notes : [...current.notes, text].slice(-6)
|
||||
}))
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'subagent.complete': {
|
||||
const p = ev.payload
|
||||
const status = p.status ?? 'completed'
|
||||
|
||||
upsertSubagent(p.task_index, p.task_count ?? 1, p.goal, current => ({
|
||||
...current,
|
||||
durationSeconds: p.duration_seconds ?? current.durationSeconds,
|
||||
goal: p.goal || current.goal,
|
||||
status,
|
||||
summary: p.summary || p.text || current.summary,
|
||||
taskCount: p.task_count ?? current.taskCount
|
||||
}))
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'message.delta': {
|
||||
const p = ev.payload
|
||||
pruneTransient()
|
||||
endReasoningPhase()
|
||||
|
||||
|
|
@ -397,7 +625,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'message.complete': {
|
||||
const p = ev.payload
|
||||
const finalText = (p?.rendered ?? p?.text ?? bufRef.current).trimStart()
|
||||
const persisted = persistedToolLabelsRef.current
|
||||
const savedReasoning = reasoningRef.current.trim()
|
||||
|
|
@ -432,7 +663,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
persistedToolLabelsRef.current.clear()
|
||||
setActivity([])
|
||||
bufRef.current = ''
|
||||
patchUiState({ status: 'ready' })
|
||||
setStatus('ready')
|
||||
|
||||
if (p?.usage) {
|
||||
patchUiState({ usage: p.usage })
|
||||
|
|
@ -451,7 +682,8 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
break
|
||||
}
|
||||
|
||||
case 'error':
|
||||
case 'error': {
|
||||
const p = ev.payload
|
||||
idle()
|
||||
clearReasoning()
|
||||
turnToolsRef.current = []
|
||||
|
|
@ -464,9 +696,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
|
||||
pushActivity(String(p?.message || 'unknown error'), 'error')
|
||||
sys(`error: ${p?.message}`)
|
||||
patchUiState({ status: 'ready' })
|
||||
setStatus('ready')
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,12 @@
|
|||
import { HOTKEYS } from '../constants.js'
|
||||
import type {
|
||||
BackgroundStartResponse,
|
||||
SessionHistoryResponse,
|
||||
SlashExecResponse,
|
||||
ToolsConfigureResponse,
|
||||
ToolsListResponse,
|
||||
ToolsShowResponse
|
||||
} from '../gatewayTypes.js'
|
||||
import { writeOsc52Clipboard } from '../lib/osc52.js'
|
||||
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
|
||||
import { fmtK } from '../lib/text.js'
|
||||
|
|
@ -12,7 +20,7 @@ import { getUiState, patchUiState } from './uiStore.js'
|
|||
export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => boolean {
|
||||
const { enqueue, hasSelection, paste, queueRef, selection, setInput } = ctx.composer
|
||||
const { gw, rpc } = ctx.gateway
|
||||
const { catalog, lastUserMsg, maybeWarn, messages } = ctx.local
|
||||
const { catalog, getHistoryItems, getLastUserMsg, maybeWarn } = ctx.local
|
||||
|
||||
const {
|
||||
closeSession,
|
||||
|
|
@ -24,9 +32,24 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
|
|||
setSessionStartedAt
|
||||
} = ctx.session
|
||||
|
||||
const { page, panel, send, setHistoryItems, setMessages, sys, trimLastExchange } = ctx.transcript
|
||||
const { page, panel, send, setHistoryItems, sys, trimLastExchange } = ctx.transcript
|
||||
const { setVoiceEnabled } = ctx.voice
|
||||
|
||||
const showSlashOutput = (title: string, command: string) => {
|
||||
gw.request<SlashExecResponse>('slash.exec', { command, session_id: getUiState().sid })
|
||||
.then(r => {
|
||||
const text = r?.warning ? `warning: ${r.warning}\n${r?.output || '(no output)'}` : r?.output || '(no output)'
|
||||
const lines = text.split('\n').filter(Boolean)
|
||||
|
||||
if (lines.length > 2 || text.length > 180) {
|
||||
page(text, title)
|
||||
} else {
|
||||
sys(text)
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
|
||||
}
|
||||
|
||||
const handler = (cmd: string): boolean => {
|
||||
const ui = getUiState()
|
||||
const detailsMode = ui.detailsMode
|
||||
|
|
@ -160,7 +183,7 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
|
|||
}
|
||||
}
|
||||
|
||||
const all = messages.filter((m: any) => m.role === 'assistant')
|
||||
const all = getHistoryItems().filter((m: any) => m.role === 'assistant')
|
||||
|
||||
if (arg && Number.isNaN(parseInt(arg, 10))) {
|
||||
sys('usage: /copy [number]')
|
||||
|
|
@ -244,7 +267,6 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
|
|||
}
|
||||
|
||||
if (r.removed > 0) {
|
||||
setMessages((prev: any[]) => trimLastExchange(prev))
|
||||
setHistoryItems((prev: any[]) => trimLastExchange(prev))
|
||||
sys(`undid ${r.removed} messages`)
|
||||
} else {
|
||||
|
|
@ -253,8 +275,9 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
|
|||
})
|
||||
|
||||
return true
|
||||
case 'retry': {
|
||||
const lastUserMsg = getLastUserMsg()
|
||||
|
||||
case 'retry':
|
||||
if (!lastUserMsg) {
|
||||
sys('nothing to retry')
|
||||
|
||||
|
|
@ -273,7 +296,6 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
|
|||
return
|
||||
}
|
||||
|
||||
setMessages((prev: any[]) => trimLastExchange(prev))
|
||||
setHistoryItems((prev: any[]) => trimLastExchange(prev))
|
||||
send(lastUserMsg)
|
||||
})
|
||||
|
|
@ -284,6 +306,7 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
|
|||
send(lastUserMsg)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
case 'background':
|
||||
|
||||
|
|
@ -294,13 +317,15 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
|
|||
return true
|
||||
}
|
||||
|
||||
rpc('prompt.background', { session_id: sid, text: arg }).then((r: any) => {
|
||||
if (!r?.task_id) {
|
||||
rpc<BackgroundStartResponse>('prompt.background', { session_id: sid, text: arg }).then(r => {
|
||||
const taskId = r?.task_id
|
||||
|
||||
if (!taskId) {
|
||||
return
|
||||
}
|
||||
|
||||
patchUiState(state => ({ ...state, bgTasks: new Set(state.bgTasks).add(r.task_id) }))
|
||||
sys(`bg ${r.task_id} started`)
|
||||
patchUiState(state => ({ ...state, bgTasks: new Set(state.bgTasks).add(taskId) }))
|
||||
sys(`bg ${taskId} started`)
|
||||
})
|
||||
|
||||
return true
|
||||
|
|
@ -483,7 +508,6 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
|
|||
|
||||
if (Array.isArray(r.messages)) {
|
||||
const resumed = toTranscriptMessages(r.messages)
|
||||
setMessages(resumed)
|
||||
setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed)
|
||||
}
|
||||
|
||||
|
|
@ -526,7 +550,6 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
|
|||
patchUiState({ sid: r.session_id })
|
||||
setSessionStartedAt(Date.now())
|
||||
setHistoryItems([])
|
||||
setMessages([])
|
||||
sys(`branched → ${r.title}`)
|
||||
}
|
||||
})
|
||||
|
|
@ -547,6 +570,26 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
|
|||
|
||||
return true
|
||||
|
||||
case 'fast':
|
||||
showSlashOutput('Fast', cmd.slice(1))
|
||||
|
||||
return true
|
||||
|
||||
case 'debug':
|
||||
showSlashOutput('Debug', cmd.slice(1))
|
||||
|
||||
return true
|
||||
|
||||
case 'snapshot':
|
||||
showSlashOutput('Snapshot', cmd.slice(1))
|
||||
|
||||
return true
|
||||
|
||||
case 'platforms':
|
||||
showSlashOutput('Platforms', cmd.slice(1))
|
||||
|
||||
return true
|
||||
|
||||
case 'title':
|
||||
rpc('session.title', { session_id: sid, ...(arg ? { title: arg } : {}) }).then((r: any) => {
|
||||
if (!r) {
|
||||
|
|
@ -618,12 +661,28 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
|
|||
return true
|
||||
|
||||
case 'history':
|
||||
rpc('session.history', { session_id: sid }).then((r: any) => {
|
||||
rpc<SessionHistoryResponse>('session.history', { session_id: sid }).then(r => {
|
||||
if (typeof r?.count !== 'number') {
|
||||
return
|
||||
}
|
||||
|
||||
sys(`${r.count} messages`)
|
||||
if (!r.messages?.length) {
|
||||
sys(`${r.count} messages`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const text = r.messages
|
||||
.map((msg, index) => {
|
||||
if (msg.role === 'tool') {
|
||||
return `[Tool #${index + 1}] ${msg.name || 'tool'} ${msg.context || ''}`.trim()
|
||||
}
|
||||
|
||||
return `[${msg.role === 'assistant' ? 'Hermes' : msg.role === 'user' ? 'You' : 'System'} #${index + 1}] ${msg.text || ''}`.trim()
|
||||
})
|
||||
.join('\n\n')
|
||||
|
||||
page(text, `History (${r.count})`)
|
||||
})
|
||||
|
||||
return true
|
||||
|
|
@ -917,29 +976,98 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
|
|||
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
|
||||
|
||||
return true
|
||||
case 'tools': {
|
||||
const [subcommand, ...names] = arg.trim().split(/\s+/).filter(Boolean)
|
||||
|
||||
case 'tools':
|
||||
rpc('tools.list', { session_id: sid })
|
||||
.then((r: any) => {
|
||||
if (!r) {
|
||||
return
|
||||
}
|
||||
if (!subcommand) {
|
||||
rpc<ToolsShowResponse>('tools.show', { session_id: sid })
|
||||
.then(r => {
|
||||
if (!r?.sections?.length) {
|
||||
return sys('no tools')
|
||||
}
|
||||
|
||||
if (!r.toolsets?.length) {
|
||||
return sys('no tools')
|
||||
}
|
||||
panel(
|
||||
`Tools${typeof r.total === 'number' ? ` (${r.total})` : ''}`,
|
||||
r.sections.map(section => ({
|
||||
title: section.name,
|
||||
rows: section.tools.map(tool => [tool.name, tool.description] as [string, string])
|
||||
}))
|
||||
)
|
||||
})
|
||||
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
|
||||
|
||||
panel(
|
||||
'Tools',
|
||||
r.toolsets.map((ts: any) => ({
|
||||
title: `${ts.enabled ? '*' : ' '} ${ts.name} [${ts.tool_count} tools]`,
|
||||
items: ts.tools
|
||||
}))
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
if (subcommand === 'list') {
|
||||
rpc<ToolsListResponse>('tools.list', { session_id: sid })
|
||||
.then(r => {
|
||||
if (!r?.toolsets?.length) {
|
||||
return sys('no tools')
|
||||
}
|
||||
|
||||
panel(
|
||||
'Tools',
|
||||
r.toolsets.map(ts => ({
|
||||
title: `${ts.enabled ? '*' : ' '} ${ts.name} [${ts.tool_count} tools]`,
|
||||
items: ts.tools
|
||||
}))
|
||||
)
|
||||
})
|
||||
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (subcommand === 'disable' || subcommand === 'enable') {
|
||||
if (!names.length) {
|
||||
sys(`usage: /tools ${subcommand} <name> [name ...]`)
|
||||
sys(`built-in toolset: /tools ${subcommand} web`)
|
||||
sys(`MCP tool: /tools ${subcommand} github:create_issue`)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
rpc<ToolsConfigureResponse>('tools.configure', {
|
||||
action: subcommand,
|
||||
names,
|
||||
session_id: sid
|
||||
})
|
||||
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
|
||||
.then(r => {
|
||||
if (!r) {
|
||||
return
|
||||
}
|
||||
|
||||
if (r.info) {
|
||||
setSessionStartedAt(Date.now())
|
||||
resetVisibleHistory(r.info)
|
||||
}
|
||||
|
||||
if (r.changed?.length) {
|
||||
sys(`${subcommand === 'disable' ? 'disabled' : 'enabled'}: ${r.changed.join(', ')}`)
|
||||
}
|
||||
|
||||
if (r.unknown?.length) {
|
||||
sys(`unknown toolsets: ${r.unknown.join(', ')}`)
|
||||
}
|
||||
|
||||
if (r.missing_servers?.length) {
|
||||
sys(`missing MCP servers: ${r.missing_servers.join(', ')}`)
|
||||
}
|
||||
|
||||
if (r.reset) {
|
||||
sys('session reset. new tool configuration is active.')
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
sys('usage: /tools [list|disable|enable] ...')
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
case 'toolsets':
|
||||
rpc('toolsets.list', { session_id: sid })
|
||||
|
|
@ -969,6 +1097,28 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
|
|||
return true
|
||||
|
||||
default:
|
||||
if (catalog?.canon) {
|
||||
const needle = `/${name}`.toLowerCase()
|
||||
|
||||
const matches = [
|
||||
...new Set(
|
||||
Object.entries(catalog.canon)
|
||||
.filter(([alias]) => alias.startsWith(needle))
|
||||
.map(([, canon]) => canon)
|
||||
)
|
||||
]
|
||||
|
||||
if (matches.length === 1 && matches[0]!.toLowerCase() !== needle) {
|
||||
return handler(`${matches[0]}${arg ? ' ' + arg : ''}`)
|
||||
}
|
||||
|
||||
if (matches.length > 1) {
|
||||
sys(`ambiguous command: ${matches.slice(0, 6).join(', ')}${matches.length > 6 ? ', …' : ''}`)
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
gw.request('slash.exec', { command: cmd.slice(1), session_id: sid })
|
||||
.then((r: any) => {
|
||||
sys(
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import type {
|
|||
SecretReq,
|
||||
SessionInfo,
|
||||
SlashCatalog,
|
||||
SubagentProgress,
|
||||
SudoReq,
|
||||
Usage
|
||||
} from '../types.js'
|
||||
|
|
@ -35,7 +36,7 @@ export interface CompletionItem {
|
|||
}
|
||||
|
||||
export interface GatewayRpc {
|
||||
(method: string, params?: Record<string, unknown>): Promise<RpcResult | null>
|
||||
<T extends RpcResult = RpcResult>(method: string, params?: Record<string, unknown>): Promise<T | null>
|
||||
}
|
||||
|
||||
export interface GatewayServices {
|
||||
|
|
@ -176,6 +177,7 @@ export interface TurnActions {
|
|||
setToolTokens: StateSetter<number>
|
||||
setReasoningStreaming: StateSetter<boolean>
|
||||
setStreaming: StateSetter<string>
|
||||
setSubagents: StateSetter<SubagentProgress[]>
|
||||
setTools: StateSetter<ActiveTool[]>
|
||||
setTurnTrail: StateSetter<string[]>
|
||||
}
|
||||
|
|
@ -204,6 +206,7 @@ export interface TurnState {
|
|||
reasoningActive: boolean
|
||||
reasoningStreaming: boolean
|
||||
streaming: string
|
||||
subagents: SubagentProgress[]
|
||||
toolTokens: number
|
||||
tools: ActiveTool[]
|
||||
turnTrail: string[]
|
||||
|
|
@ -278,7 +281,6 @@ export interface GatewayEventHandlerContext {
|
|||
transcript: {
|
||||
appendMessage: (msg: Msg) => void
|
||||
setHistoryItems: StateSetter<Msg[]>
|
||||
setMessages: StateSetter<Msg[]>
|
||||
}
|
||||
turn: {
|
||||
actions: Pick<
|
||||
|
|
@ -295,6 +297,7 @@ export interface GatewayEventHandlerContext {
|
|||
| 'setActivity'
|
||||
| 'setReasoningTokens'
|
||||
| 'setStreaming'
|
||||
| 'setSubagents'
|
||||
| 'setToolTokens'
|
||||
| 'setTools'
|
||||
| 'setTurnTrail'
|
||||
|
|
@ -328,9 +331,9 @@ export interface SlashHandlerContext {
|
|||
gateway: GatewayServices
|
||||
local: {
|
||||
catalog: SlashCatalog | null
|
||||
lastUserMsg: string
|
||||
getHistoryItems: () => Msg[]
|
||||
getLastUserMsg: () => string
|
||||
maybeWarn: (value: any) => void
|
||||
messages: Msg[]
|
||||
}
|
||||
session: {
|
||||
closeSession: (targetSid?: string | null) => Promise<unknown>
|
||||
|
|
@ -346,7 +349,6 @@ export interface SlashHandlerContext {
|
|||
panel: (title: string, sections: PanelSection[]) => void
|
||||
send: (text: string) => void
|
||||
setHistoryItems: StateSetter<Msg[]>
|
||||
setMessages: StateSetter<Msg[]>
|
||||
sys: (text: string) => void
|
||||
trimLastExchange: (items: Msg[]) => Msg[]
|
||||
}
|
||||
|
|
@ -389,6 +391,7 @@ export interface AppLayoutProgressProps {
|
|||
showProgressArea: boolean
|
||||
showStreamingArea: boolean
|
||||
streaming: string
|
||||
subagents: SubagentProgress[]
|
||||
toolTokens: number
|
||||
tools: ActiveTool[]
|
||||
turnTrail: string[]
|
||||
|
|
@ -396,7 +399,7 @@ export interface AppLayoutProgressProps {
|
|||
|
||||
export interface AppLayoutStatusProps {
|
||||
cwdLabel: string
|
||||
durationLabel: string
|
||||
sessionStartedAt: number | null
|
||||
showStickyPrompt: boolean
|
||||
statusColor: string
|
||||
stickyPrompt: string
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { isTransientTrailLine, sameToolTrailGroup } from '../lib/text.js'
|
||||
import type { ActiveTool, ActivityItem } from '../types.js'
|
||||
import { estimateTokensRough, isTransientTrailLine, sameToolTrailGroup } from '../lib/text.js'
|
||||
import type { ActiveTool, ActivityItem, SubagentProgress } from '../types.js'
|
||||
|
||||
import { REASONING_PULSE_MS, STREAM_BATCH_MS } from './constants.js'
|
||||
import type { InterruptTurnOptions, ToolCompleteRibbon, UseTurnStateResult } from './interfaces.js'
|
||||
|
|
@ -16,6 +16,7 @@ export function useTurnState(): UseTurnStateResult {
|
|||
const [toolTokens, setToolTokens] = useState(0)
|
||||
const [reasoningStreaming, setReasoningStreaming] = useState(false)
|
||||
const [streaming, setStreaming] = useState('')
|
||||
const [subagents, setSubagents] = useState<SubagentProgress[]>([])
|
||||
const [tools, setTools] = useState<ActiveTool[]>([])
|
||||
const [turnTrail, setTurnTrail] = useState<string[]>([])
|
||||
|
||||
|
|
@ -73,6 +74,7 @@ export function useTurnState(): UseTurnStateResult {
|
|||
reasoningTimerRef.current = setTimeout(() => {
|
||||
reasoningTimerRef.current = null
|
||||
setReasoning(reasoningRef.current)
|
||||
setReasoningTokens(estimateTokensRough(reasoningRef.current))
|
||||
}, STREAM_BATCH_MS)
|
||||
}, [])
|
||||
|
||||
|
|
@ -147,6 +149,7 @@ export function useTurnState(): UseTurnStateResult {
|
|||
const idle = useCallback(() => {
|
||||
endReasoningPhase()
|
||||
activeToolsRef.current = []
|
||||
setSubagents([])
|
||||
setTools([])
|
||||
setTurnTrail([])
|
||||
patchUiState({ busy: false })
|
||||
|
|
@ -165,7 +168,7 @@ export function useTurnState(): UseTurnStateResult {
|
|||
({ appendMessage, gw, sid, sys }: InterruptTurnOptions) => {
|
||||
interruptedRef.current = true
|
||||
gw.request('session.interrupt', { session_id: sid }).catch(() => {})
|
||||
const partial = (streaming || bufRef.current).trimStart()
|
||||
const partial = bufRef.current.trimStart()
|
||||
|
||||
if (partial) {
|
||||
appendMessage({ role: 'assistant', text: partial + '\n\n*[interrupted]*' })
|
||||
|
|
@ -188,7 +191,7 @@ export function useTurnState(): UseTurnStateResult {
|
|||
patchUiState({ status: 'ready' })
|
||||
}, 1500)
|
||||
},
|
||||
[clearReasoning, idle, streaming]
|
||||
[clearReasoning, idle]
|
||||
)
|
||||
|
||||
const actions = useMemo(
|
||||
|
|
@ -210,6 +213,7 @@ export function useTurnState(): UseTurnStateResult {
|
|||
setToolTokens,
|
||||
setReasoningStreaming,
|
||||
setStreaming,
|
||||
setSubagents,
|
||||
setTools,
|
||||
setTurnTrail
|
||||
}),
|
||||
|
|
@ -256,10 +260,22 @@ export function useTurnState(): UseTurnStateResult {
|
|||
toolTokens,
|
||||
reasoningStreaming,
|
||||
streaming,
|
||||
subagents,
|
||||
tools,
|
||||
turnTrail
|
||||
}),
|
||||
[activity, reasoning, reasoningTokens, reasoningActive, toolTokens, reasoningStreaming, streaming, tools, turnTrail]
|
||||
[
|
||||
activity,
|
||||
reasoning,
|
||||
reasoningTokens,
|
||||
reasoningActive,
|
||||
toolTokens,
|
||||
reasoningStreaming,
|
||||
streaming,
|
||||
subagents,
|
||||
tools,
|
||||
turnTrail
|
||||
]
|
||||
)
|
||||
|
||||
return {
|
||||
|
|
|
|||
40
ui-tui/src/app/widgetStore.ts
Normal file
40
ui-tui/src/app/widgetStore.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { atom } from 'nanostores'
|
||||
|
||||
import { WIDGET_CATALOG } from '../widgets.js'
|
||||
|
||||
export interface WidgetState {
|
||||
enabled: Record<string, boolean>
|
||||
params: Record<string, Record<string, string>>
|
||||
}
|
||||
|
||||
function defaults(): WidgetState {
|
||||
const enabled: Record<string, boolean> = {}
|
||||
|
||||
for (const w of WIDGET_CATALOG) {
|
||||
enabled[w.id] = w.defaultOn
|
||||
}
|
||||
|
||||
return { enabled, params: {} }
|
||||
}
|
||||
|
||||
export const $widgetState = atom<WidgetState>(defaults())
|
||||
|
||||
export function toggleWidget(id: string, force?: boolean) {
|
||||
const s = $widgetState.get()
|
||||
const next = force ?? !s.enabled[id]
|
||||
|
||||
$widgetState.set({ ...s, enabled: { ...s.enabled, [id]: next } })
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
export function setWidgetParam(id: string, key: string, value: string) {
|
||||
const s = $widgetState.get()
|
||||
const prev = s.params[id] ?? {}
|
||||
|
||||
$widgetState.set({ ...s, params: { ...s.params, [id]: { ...prev, [key]: value } } })
|
||||
}
|
||||
|
||||
export function getWidgetEnabled(id: string): boolean {
|
||||
return $widgetState.get().enabled[id] ?? false
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { Box, type ScrollBoxHandle, Text } from '@hermes/ink'
|
||||
import { type ReactNode, type RefObject, useCallback, useEffect, useState, useSyncExternalStore } from 'react'
|
||||
|
||||
import { stickyPromptFromViewport } from '../app/helpers.js'
|
||||
import { fmtDuration, stickyPromptFromViewport } from '../app/helpers.js'
|
||||
import { fmtK } from '../lib/text.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
import type { Msg, Usage } from '../types.js'
|
||||
|
|
@ -33,6 +33,19 @@ function ctxBar(pct: number | undefined, w = 10) {
|
|||
return '█'.repeat(filled) + '░'.repeat(w - filled)
|
||||
}
|
||||
|
||||
function SessionDuration({ startedAt }: { startedAt: number }) {
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
|
||||
useEffect(() => {
|
||||
setNow(Date.now())
|
||||
const id = setInterval(() => setNow(Date.now()), 1000)
|
||||
|
||||
return () => clearInterval(id)
|
||||
}, [startedAt])
|
||||
|
||||
return fmtDuration(now - startedAt)
|
||||
}
|
||||
|
||||
export function StatusRule({
|
||||
cwdLabel,
|
||||
cols,
|
||||
|
|
@ -41,7 +54,7 @@ export function StatusRule({
|
|||
model,
|
||||
usage,
|
||||
bgCount,
|
||||
durationLabel,
|
||||
sessionStartedAt,
|
||||
voiceLabel,
|
||||
t
|
||||
}: {
|
||||
|
|
@ -52,7 +65,7 @@ export function StatusRule({
|
|||
model: string
|
||||
usage: Usage
|
||||
bgCount: number
|
||||
durationLabel?: string
|
||||
sessionStartedAt?: number | null
|
||||
voiceLabel?: string
|
||||
t: Theme
|
||||
}) {
|
||||
|
|
@ -83,7 +96,12 @@ export function StatusRule({
|
|||
<Text color={barColor}>[{bar}]</Text> <Text color={barColor}>{pctLabel}</Text>
|
||||
</Text>
|
||||
) : null}
|
||||
{durationLabel ? <Text color={t.color.dim}> │ {durationLabel}</Text> : null}
|
||||
{sessionStartedAt ? (
|
||||
<Text color={t.color.dim}>
|
||||
{' │ '}
|
||||
<SessionDuration startedAt={sessionStartedAt} />
|
||||
</Text>
|
||||
) : null}
|
||||
{voiceLabel ? <Text color={t.color.dim}> │ {voiceLabel}</Text> : null}
|
||||
{bgCount > 0 ? <Text color={t.color.dim}> │ {bgCount} bg</Text> : null}
|
||||
</Text>
|
||||
|
|
@ -177,6 +195,8 @@ export function TranscriptScrollbar({ scrollRef, t }: { scrollRef: RefObject<Scr
|
|||
const travel = Math.max(1, vp - thumb)
|
||||
const pos = Math.max(0, (s?.getScrollTop() ?? 0) + (s?.getPendingDelta() ?? 0))
|
||||
const thumbTop = scrollable ? Math.round((pos / Math.max(1, total - vp)) * travel) : 0
|
||||
const thumbColor = grab !== null ? t.color.gold : hover ? t.color.amber : t.color.bronze
|
||||
const trackColor = hover ? t.color.bronze : t.color.dim
|
||||
|
||||
const jump = (row: number, offset: number) => {
|
||||
if (!s || !scrollable) {
|
||||
|
|
@ -203,25 +223,27 @@ export function TranscriptScrollbar({ scrollRef, t }: { scrollRef: RefObject<Scr
|
|||
onMouseUp={() => setGrab(null)}
|
||||
width={1}
|
||||
>
|
||||
{Array.from({ length: vp }, (_, i) => {
|
||||
const active = i >= thumbTop && i < thumbTop + thumb
|
||||
|
||||
const color = active
|
||||
? grab !== null
|
||||
? t.color.gold
|
||||
: hover
|
||||
? t.color.amber
|
||||
: t.color.bronze
|
||||
: hover
|
||||
? t.color.bronze
|
||||
: t.color.dim
|
||||
|
||||
return (
|
||||
<Text color={color} dimColor={!active && !hover} key={i}>
|
||||
{scrollable ? (active ? '┃' : '│') : ' '}
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
{!scrollable ? (
|
||||
<Text color={trackColor} dimColor>
|
||||
{' \n'.repeat(Math.max(0, vp - 1))}{' '}
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
{thumbTop > 0 ? (
|
||||
<Text color={trackColor} dimColor={!hover}>
|
||||
{`${'│\n'.repeat(Math.max(0, thumbTop - 1))}${thumbTop > 0 ? '│' : ''}`}
|
||||
</Text>
|
||||
) : null}
|
||||
{thumb > 0 ? (
|
||||
<Text color={thumbColor}>{`${'┃\n'.repeat(Math.max(0, thumb - 1))}${thumb > 0 ? '┃' : ''}`}</Text>
|
||||
) : null}
|
||||
{vp - thumbTop - thumb > 0 ? (
|
||||
<Text color={trackColor} dimColor={!hover}>
|
||||
{`${'│\n'.repeat(Math.max(0, vp - thumbTop - thumb - 1))}${vp - thumbTop - thumb > 0 ? '│' : ''}`}
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { AlternateScreen, Box, NoSelect, ScrollBox, Text } from '@hermes/ink'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { memo } from 'react'
|
||||
|
||||
import { PLACEHOLDER } from '../app/constants.js'
|
||||
import type { AppLayoutProps } from '../app/interfaces.js'
|
||||
|
|
@ -14,172 +15,203 @@ import { QueuedMessages } from './queuedMessages.js'
|
|||
import { TextInput } from './textInput.js'
|
||||
import { ToolTrail } from './thinking.js'
|
||||
|
||||
export function AppLayout({ actions, composer, mouseTracking, progress, status, transcript }: AppLayoutProps) {
|
||||
const TranscriptPane = memo(function TranscriptPane({
|
||||
actions,
|
||||
composer,
|
||||
progress,
|
||||
transcript
|
||||
}: Pick<AppLayoutProps, 'actions' | 'composer' | 'progress' | 'transcript'>) {
|
||||
const ui = useStore($uiState)
|
||||
const isBlocked = useStore($isBlocked)
|
||||
const visibleHistory = transcript.virtualRows.slice(transcript.virtualHistory.start, transcript.virtualHistory.end)
|
||||
|
||||
return (
|
||||
<AlternateScreen mouseTracking={mouseTracking}>
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
<Box flexDirection="row" flexGrow={1}>
|
||||
<ScrollBox flexDirection="column" flexGrow={1} flexShrink={1} ref={transcript.scrollRef} stickyScroll>
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
{transcript.virtualHistory.topSpacer > 0 ? <Box height={transcript.virtualHistory.topSpacer} /> : null}
|
||||
<>
|
||||
<ScrollBox flexDirection="column" flexGrow={1} flexShrink={1} ref={transcript.scrollRef} stickyScroll>
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
{transcript.virtualHistory.topSpacer > 0 ? <Box height={transcript.virtualHistory.topSpacer} /> : null}
|
||||
|
||||
{visibleHistory.map(row => (
|
||||
<Box flexDirection="column" key={row.key} ref={transcript.virtualHistory.measureRef(row.key)}>
|
||||
{row.msg.kind === 'intro' && row.msg.info ? (
|
||||
<Box flexDirection="column" paddingTop={1}>
|
||||
<Banner t={ui.theme} />
|
||||
<SessionPanel info={row.msg.info} sid={ui.sid} t={ui.theme} />
|
||||
</Box>
|
||||
) : row.msg.kind === 'panel' && row.msg.panelData ? (
|
||||
<Panel sections={row.msg.panelData.sections} t={ui.theme} title={row.msg.panelData.title} />
|
||||
) : (
|
||||
<MessageLine
|
||||
cols={composer.cols}
|
||||
compact={ui.compact}
|
||||
detailsMode={ui.detailsMode}
|
||||
msg={row.msg}
|
||||
t={ui.theme}
|
||||
/>
|
||||
)}
|
||||
{visibleHistory.map(row => (
|
||||
<Box flexDirection="column" key={row.key} ref={transcript.virtualHistory.measureRef(row.key)}>
|
||||
{row.msg.kind === 'intro' && row.msg.info ? (
|
||||
<Box flexDirection="column" paddingTop={1}>
|
||||
<Banner t={ui.theme} />
|
||||
<SessionPanel info={row.msg.info} sid={ui.sid} t={ui.theme} />
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{transcript.virtualHistory.bottomSpacer > 0 ? (
|
||||
<Box height={transcript.virtualHistory.bottomSpacer} />
|
||||
) : null}
|
||||
|
||||
{progress.showProgressArea && (
|
||||
<ToolTrail
|
||||
activity={progress.activity}
|
||||
busy={ui.busy && !progress.streaming}
|
||||
detailsMode={ui.detailsMode}
|
||||
reasoning={progress.reasoning}
|
||||
reasoningActive={progress.reasoningActive}
|
||||
reasoningStreaming={progress.reasoningStreaming}
|
||||
reasoningTokens={progress.reasoningTokens}
|
||||
t={ui.theme}
|
||||
tools={progress.tools}
|
||||
toolTokens={progress.toolTokens}
|
||||
trail={progress.turnTrail}
|
||||
/>
|
||||
)}
|
||||
|
||||
{progress.showStreamingArea && (
|
||||
) : row.msg.kind === 'panel' && row.msg.panelData ? (
|
||||
<Panel sections={row.msg.panelData.sections} t={ui.theme} title={row.msg.panelData.title} />
|
||||
) : (
|
||||
<MessageLine
|
||||
cols={composer.cols}
|
||||
compact={ui.compact}
|
||||
detailsMode={ui.detailsMode}
|
||||
isStreaming
|
||||
msg={{ role: 'assistant', text: progress.streaming }}
|
||||
msg={row.msg}
|
||||
t={ui.theme}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</ScrollBox>
|
||||
))}
|
||||
|
||||
<NoSelect flexShrink={0} marginLeft={1}>
|
||||
<TranscriptScrollbar scrollRef={transcript.scrollRef} t={ui.theme} />
|
||||
</NoSelect>
|
||||
{transcript.virtualHistory.bottomSpacer > 0 ? <Box height={transcript.virtualHistory.bottomSpacer} /> : null}
|
||||
|
||||
<StickyPromptTracker
|
||||
messages={transcript.historyItems}
|
||||
offsets={transcript.virtualHistory.offsets}
|
||||
onChange={actions.setStickyPrompt}
|
||||
scrollRef={transcript.scrollRef}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<NoSelect flexDirection="column" flexShrink={0} fromLeftEdge paddingX={1}>
|
||||
<QueuedMessages
|
||||
cols={composer.cols}
|
||||
queued={composer.queuedDisplay}
|
||||
queueEditIdx={composer.queueEditIdx}
|
||||
t={ui.theme}
|
||||
/>
|
||||
|
||||
{ui.bgTasks.size > 0 && (
|
||||
<Text color={ui.theme.color.dim as any}>
|
||||
{ui.bgTasks.size} background {ui.bgTasks.size === 1 ? 'task' : 'tasks'} running
|
||||
</Text>
|
||||
{progress.showProgressArea && (
|
||||
<ToolTrail
|
||||
activity={progress.activity}
|
||||
busy={ui.busy && !progress.streaming}
|
||||
detailsMode={ui.detailsMode}
|
||||
reasoning={progress.reasoning}
|
||||
reasoningActive={progress.reasoningActive}
|
||||
reasoningStreaming={progress.reasoningStreaming}
|
||||
reasoningTokens={progress.reasoningTokens}
|
||||
subagents={progress.subagents}
|
||||
t={ui.theme}
|
||||
tools={progress.tools}
|
||||
toolTokens={progress.toolTokens}
|
||||
trail={progress.turnTrail}
|
||||
/>
|
||||
)}
|
||||
|
||||
{status.showStickyPrompt ? (
|
||||
<Text color={ui.theme.color.dim as any} wrap="truncate-end">
|
||||
<Text color={ui.theme.color.label as any}>↳ </Text>
|
||||
{status.stickyPrompt}
|
||||
</Text>
|
||||
) : (
|
||||
<Text> </Text>
|
||||
)}
|
||||
|
||||
<Box flexDirection="column" position="relative">
|
||||
{ui.statusBar && (
|
||||
<StatusRule
|
||||
bgCount={ui.bgTasks.size}
|
||||
cols={composer.cols}
|
||||
cwdLabel={status.cwdLabel}
|
||||
durationLabel={status.durationLabel}
|
||||
model={ui.info?.model?.split('/').pop() ?? ''}
|
||||
status={ui.status}
|
||||
statusColor={status.statusColor}
|
||||
t={ui.theme}
|
||||
usage={ui.usage}
|
||||
voiceLabel={status.voiceLabel}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AppOverlays
|
||||
{progress.showStreamingArea && (
|
||||
<MessageLine
|
||||
cols={composer.cols}
|
||||
compIdx={composer.compIdx}
|
||||
completions={composer.completions}
|
||||
onApprovalChoice={actions.answerApproval}
|
||||
onClarifyAnswer={actions.answerClarify}
|
||||
onModelSelect={actions.onModelSelect}
|
||||
onPickerSelect={actions.resumeById}
|
||||
onSecretSubmit={actions.answerSecret}
|
||||
onSudoSubmit={actions.answerSudo}
|
||||
pagerPageSize={composer.pagerPageSize}
|
||||
compact={ui.compact}
|
||||
detailsMode={ui.detailsMode}
|
||||
isStreaming
|
||||
msg={{ role: 'assistant', text: progress.streaming }}
|
||||
t={ui.theme}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</ScrollBox>
|
||||
|
||||
<NoSelect flexShrink={0} marginLeft={1}>
|
||||
<TranscriptScrollbar scrollRef={transcript.scrollRef} t={ui.theme} />
|
||||
</NoSelect>
|
||||
|
||||
<StickyPromptTracker
|
||||
messages={transcript.historyItems}
|
||||
offsets={transcript.virtualHistory.offsets}
|
||||
onChange={actions.setStickyPrompt}
|
||||
scrollRef={transcript.scrollRef}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
const ComposerPane = memo(function ComposerPane({
|
||||
actions,
|
||||
composer,
|
||||
status
|
||||
}: Pick<AppLayoutProps, 'actions' | 'composer' | 'status'>) {
|
||||
const ui = useStore($uiState)
|
||||
const isBlocked = useStore($isBlocked)
|
||||
|
||||
return (
|
||||
<NoSelect flexDirection="column" flexShrink={0} fromLeftEdge paddingX={1}>
|
||||
<QueuedMessages
|
||||
cols={composer.cols}
|
||||
queued={composer.queuedDisplay}
|
||||
queueEditIdx={composer.queueEditIdx}
|
||||
t={ui.theme}
|
||||
/>
|
||||
|
||||
{ui.bgTasks.size > 0 && (
|
||||
<Text color={ui.theme.color.dim as any}>
|
||||
{ui.bgTasks.size} background {ui.bgTasks.size === 1 ? 'task' : 'tasks'} running
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{status.showStickyPrompt ? (
|
||||
<Text color={ui.theme.color.dim as any} wrap="truncate-end">
|
||||
<Text color={ui.theme.color.label as any}>↳ </Text>
|
||||
{status.stickyPrompt}
|
||||
</Text>
|
||||
) : (
|
||||
<Text> </Text>
|
||||
)}
|
||||
|
||||
<Box flexDirection="column" position="relative">
|
||||
{ui.statusBar && (
|
||||
<StatusRule
|
||||
bgCount={ui.bgTasks.size}
|
||||
cols={composer.cols}
|
||||
cwdLabel={status.cwdLabel}
|
||||
model={ui.info?.model?.split('/').pop() ?? ''}
|
||||
sessionStartedAt={status.sessionStartedAt}
|
||||
status={ui.status}
|
||||
statusColor={status.statusColor}
|
||||
t={ui.theme}
|
||||
usage={ui.usage}
|
||||
voiceLabel={status.voiceLabel}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AppOverlays
|
||||
cols={composer.cols}
|
||||
compIdx={composer.compIdx}
|
||||
completions={composer.completions}
|
||||
onApprovalChoice={actions.answerApproval}
|
||||
onClarifyAnswer={actions.answerClarify}
|
||||
onModelSelect={actions.onModelSelect}
|
||||
onPickerSelect={actions.resumeById}
|
||||
onSecretSubmit={actions.answerSecret}
|
||||
onSudoSubmit={actions.answerSudo}
|
||||
pagerPageSize={composer.pagerPageSize}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{!isBlocked && (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
{composer.inputBuf.map((line, i) => (
|
||||
<Box key={i}>
|
||||
<Box width={3}>
|
||||
<Text color={ui.theme.color.dim as any}>{i === 0 ? `${ui.theme.brand.prompt} ` : ' '}</Text>
|
||||
</Box>
|
||||
|
||||
<Text color={ui.theme.color.cornsilk as any}>{line || ' '}</Text>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
<Box>
|
||||
<Box width={3}>
|
||||
<Text bold color={ui.theme.color.gold as any}>
|
||||
{composer.inputBuf.length ? ' ' : `${ui.theme.brand.prompt} `}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<TextInput
|
||||
columns={Math.max(20, composer.cols - 3)}
|
||||
onChange={composer.updateInput}
|
||||
onPaste={composer.handleTextPaste}
|
||||
onSubmit={composer.submit}
|
||||
placeholder={composer.empty ? PLACEHOLDER : ui.busy ? 'Ctrl+C to interrupt…' : ''}
|
||||
value={composer.input}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!isBlocked && (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
{composer.inputBuf.map((line, i) => (
|
||||
<Box key={i}>
|
||||
<Box width={3}>
|
||||
<Text color={ui.theme.color.dim as any}>{i === 0 ? `${ui.theme.brand.prompt} ` : ' '}</Text>
|
||||
</Box>
|
||||
{!composer.empty && !ui.sid && <Text color={ui.theme.color.dim as any}>⚕ {ui.status}</Text>}
|
||||
</NoSelect>
|
||||
)
|
||||
})
|
||||
|
||||
<Text color={ui.theme.color.cornsilk as any}>{line || ' '}</Text>
|
||||
</Box>
|
||||
))}
|
||||
export const AppLayout = memo(function AppLayout({
|
||||
actions,
|
||||
composer,
|
||||
mouseTracking,
|
||||
progress,
|
||||
status,
|
||||
transcript
|
||||
}: AppLayoutProps) {
|
||||
return (
|
||||
<AlternateScreen mouseTracking={mouseTracking}>
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
<Box flexDirection="row" flexGrow={1}>
|
||||
<TranscriptPane actions={actions} composer={composer} progress={progress} transcript={transcript} />
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Box width={3}>
|
||||
<Text bold color={ui.theme.color.gold as any}>
|
||||
{composer.inputBuf.length ? ' ' : `${ui.theme.brand.prompt} `}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<TextInput
|
||||
columns={Math.max(20, composer.cols - 3)}
|
||||
onChange={composer.updateInput}
|
||||
onPaste={composer.handleTextPaste}
|
||||
onSubmit={composer.submit}
|
||||
placeholder={composer.empty ? PLACEHOLDER : ui.busy ? 'Ctrl+C to interrupt…' : ''}
|
||||
value={composer.input}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!composer.empty && !ui.sid && <Text color={ui.theme.color.dim as any}>⚕ {ui.status}</Text>}
|
||||
</NoSelect>
|
||||
<ComposerPane actions={actions} composer={composer} status={status} />
|
||||
</Box>
|
||||
</AlternateScreen>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Box, Text } from '@hermes/ink'
|
||||
import type { ReactNode } from 'react'
|
||||
import { memo, type ReactNode, useMemo } from 'react'
|
||||
|
||||
import type { Theme } from '../theme.js'
|
||||
|
||||
|
|
@ -212,367 +212,379 @@ function MdInline({ t, text }: { t: Theme; text: string }) {
|
|||
return <Text>{parts.length ? parts : <Text>{text}</Text>}</Text>
|
||||
}
|
||||
|
||||
export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: string }) {
|
||||
const lines = text.split('\n')
|
||||
const nodes: ReactNode[] = []
|
||||
let i = 0
|
||||
interface MdProps {
|
||||
compact?: boolean
|
||||
t: Theme
|
||||
text: string
|
||||
}
|
||||
|
||||
let prevKind: 'blank' | 'code' | 'heading' | 'list' | 'paragraph' | 'quote' | 'rule' | 'table' | null = null
|
||||
function MdImpl({ compact, t, text }: MdProps) {
|
||||
const nodes = useMemo(() => {
|
||||
const lines = text.split('\n')
|
||||
const nodes: ReactNode[] = []
|
||||
let i = 0
|
||||
|
||||
const gap = () => {
|
||||
if (nodes.length && prevKind !== 'blank') {
|
||||
nodes.push(<Text key={`gap-${nodes.length}`}> </Text>)
|
||||
prevKind = 'blank'
|
||||
}
|
||||
}
|
||||
let prevKind: 'blank' | 'code' | 'heading' | 'list' | 'paragraph' | 'quote' | 'rule' | 'table' | null = null
|
||||
|
||||
const start = (kind: Exclude<typeof prevKind, null | 'blank'>) => {
|
||||
if (prevKind && prevKind !== 'blank' && prevKind !== kind) {
|
||||
gap()
|
||||
const gap = () => {
|
||||
if (nodes.length && prevKind !== 'blank') {
|
||||
nodes.push(<Text key={`gap-${nodes.length}`}> </Text>)
|
||||
prevKind = 'blank'
|
||||
}
|
||||
}
|
||||
|
||||
prevKind = kind
|
||||
}
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]!
|
||||
const key = nodes.length
|
||||
|
||||
if (compact && !line.trim()) {
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (!line.trim()) {
|
||||
gap()
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const fence = parseFence(line)
|
||||
|
||||
if (fence) {
|
||||
const block: string[] = []
|
||||
const lang = fence.lang
|
||||
|
||||
for (i++; i < lines.length && !isFenceClose(lines[i]!, fence); i++) {
|
||||
block.push(lines[i]!)
|
||||
const start = (kind: Exclude<typeof prevKind, null | 'blank'>) => {
|
||||
if (prevKind && prevKind !== 'blank' && prevKind !== kind) {
|
||||
gap()
|
||||
}
|
||||
|
||||
if (i < lines.length) {
|
||||
prevKind = kind
|
||||
}
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]!
|
||||
const key = nodes.length
|
||||
|
||||
if (compact && !line.trim()) {
|
||||
i++
|
||||
}
|
||||
|
||||
if (isMarkdownFence(lang)) {
|
||||
start('paragraph')
|
||||
nodes.push(<Md compact={compact} key={key} t={t} text={block.join('\n')} />)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
start('code')
|
||||
if (!line.trim()) {
|
||||
gap()
|
||||
i++
|
||||
|
||||
const isDiff = lang === 'diff'
|
||||
|
||||
nodes.push(
|
||||
<Box flexDirection="column" key={key} paddingLeft={2}>
|
||||
{lang && !isDiff && <Text color={t.color.dim}>{'─ ' + lang}</Text>}
|
||||
{block.map((l, j) => {
|
||||
const add = isDiff && l.startsWith('+')
|
||||
const del = isDiff && l.startsWith('-')
|
||||
const hunk = isDiff && l.startsWith('@@')
|
||||
|
||||
return (
|
||||
<Text
|
||||
backgroundColor={add ? t.color.diffAdded : del ? t.color.diffRemoved : undefined}
|
||||
color={add ? t.color.diffAddedWord : del ? t.color.diffRemovedWord : hunk ? t.color.dim : undefined}
|
||||
dimColor={isDiff && !add && !del && !hunk && l.startsWith(' ')}
|
||||
key={j}
|
||||
>
|
||||
{l}
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.trim().startsWith('$$')) {
|
||||
start('code')
|
||||
|
||||
const block: string[] = []
|
||||
|
||||
for (i++; i < lines.length; i++) {
|
||||
if (lines[i]!.trim().startsWith('$$')) {
|
||||
i++
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
block.push(lines[i]!)
|
||||
continue
|
||||
}
|
||||
|
||||
nodes.push(
|
||||
<Box flexDirection="column" key={key} paddingLeft={2}>
|
||||
<Text color={t.color.dim}>─ math</Text>
|
||||
{block.map((l, j) => (
|
||||
<Text color={t.color.amber} key={j}>
|
||||
{l}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
const fence = parseFence(line)
|
||||
|
||||
continue
|
||||
}
|
||||
if (fence) {
|
||||
const block: string[] = []
|
||||
const lang = fence.lang
|
||||
|
||||
const heading = line.match(HEADING_RE)
|
||||
for (i++; i < lines.length && !isFenceClose(lines[i]!, fence); i++) {
|
||||
block.push(lines[i]!)
|
||||
}
|
||||
|
||||
if (heading) {
|
||||
start('heading')
|
||||
nodes.push(
|
||||
<Text bold color={t.color.amber} key={key}>
|
||||
{heading[2]}
|
||||
</Text>
|
||||
)
|
||||
i++
|
||||
if (i < lines.length) {
|
||||
i++
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
if (isMarkdownFence(lang)) {
|
||||
start('paragraph')
|
||||
nodes.push(<Md compact={compact} key={key} t={t} text={block.join('\n')} />)
|
||||
|
||||
if (i + 1 < lines.length && line.trim()) {
|
||||
const setext = lines[i + 1]!.match(/^\s{0,3}(=+|-+)\s*$/)
|
||||
continue
|
||||
}
|
||||
|
||||
if (setext) {
|
||||
start('code')
|
||||
|
||||
const isDiff = lang === 'diff'
|
||||
|
||||
nodes.push(
|
||||
<Box flexDirection="column" key={key} paddingLeft={2}>
|
||||
{lang && !isDiff && <Text color={t.color.dim}>{'─ ' + lang}</Text>}
|
||||
{block.map((l, j) => {
|
||||
const add = isDiff && l.startsWith('+')
|
||||
const del = isDiff && l.startsWith('-')
|
||||
const hunk = isDiff && l.startsWith('@@')
|
||||
|
||||
return (
|
||||
<Text
|
||||
backgroundColor={add ? t.color.diffAdded : del ? t.color.diffRemoved : undefined}
|
||||
color={add ? t.color.diffAddedWord : del ? t.color.diffRemovedWord : hunk ? t.color.dim : undefined}
|
||||
dimColor={isDiff && !add && !del && !hunk && l.startsWith(' ')}
|
||||
key={j}
|
||||
>
|
||||
{l}
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.trim().startsWith('$$')) {
|
||||
start('code')
|
||||
|
||||
const block: string[] = []
|
||||
|
||||
for (i++; i < lines.length; i++) {
|
||||
if (lines[i]!.trim().startsWith('$$')) {
|
||||
i++
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
block.push(lines[i]!)
|
||||
}
|
||||
|
||||
nodes.push(
|
||||
<Box flexDirection="column" key={key} paddingLeft={2}>
|
||||
<Text color={t.color.dim}>─ math</Text>
|
||||
{block.map((l, j) => (
|
||||
<Text color={t.color.amber} key={j}>
|
||||
{l}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const heading = line.match(HEADING_RE)
|
||||
|
||||
if (heading) {
|
||||
start('heading')
|
||||
nodes.push(
|
||||
<Text bold color={t.color.amber} key={key}>
|
||||
{line.trim()}
|
||||
{heading[2]}
|
||||
</Text>
|
||||
)
|
||||
i += 2
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (HR_RE.test(line)) {
|
||||
start('rule')
|
||||
nodes.push(
|
||||
<Text color={t.color.dim} key={key}>
|
||||
{'─'.repeat(36)}
|
||||
</Text>
|
||||
)
|
||||
i++
|
||||
if (i + 1 < lines.length && line.trim()) {
|
||||
const setext = lines[i + 1]!.match(/^\s{0,3}(=+|-+)\s*$/)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const footnote = line.match(FOOTNOTE_RE)
|
||||
|
||||
if (footnote) {
|
||||
start('list')
|
||||
nodes.push(
|
||||
<Text color={t.color.dim} key={key}>
|
||||
[{footnote[1]}] <MdInline t={t} text={footnote[2] ?? ''} />
|
||||
</Text>
|
||||
)
|
||||
i++
|
||||
|
||||
while (i < lines.length && /^\s{2,}\S/.test(lines[i]!)) {
|
||||
nodes.push(
|
||||
<Box key={`${key}-cont-${i}`} paddingLeft={2}>
|
||||
<Text color={t.color.dim}>
|
||||
<MdInline t={t} text={lines[i]!.trim()} />
|
||||
if (setext) {
|
||||
start('heading')
|
||||
nodes.push(
|
||||
<Text bold color={t.color.amber} key={key}>
|
||||
{line.trim()}
|
||||
</Text>
|
||||
)
|
||||
i += 2
|
||||
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (HR_RE.test(line)) {
|
||||
start('rule')
|
||||
nodes.push(
|
||||
<Text color={t.color.dim} key={key}>
|
||||
{'─'.repeat(36)}
|
||||
</Text>
|
||||
)
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const footnote = line.match(FOOTNOTE_RE)
|
||||
|
||||
if (footnote) {
|
||||
start('list')
|
||||
nodes.push(
|
||||
<Text color={t.color.dim} key={key}>
|
||||
[{footnote[1]}] <MdInline t={t} text={footnote[2] ?? ''} />
|
||||
</Text>
|
||||
)
|
||||
i++
|
||||
|
||||
while (i < lines.length && /^\s{2,}\S/.test(lines[i]!)) {
|
||||
nodes.push(
|
||||
<Box key={`${key}-cont-${i}`} paddingLeft={2}>
|
||||
<Text color={t.color.dim}>
|
||||
<MdInline t={t} text={lines[i]!.trim()} />
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
i++
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (i + 1 < lines.length && DEF_RE.test(lines[i + 1]!)) {
|
||||
start('list')
|
||||
nodes.push(
|
||||
<Text bold key={key}>
|
||||
{line.trim()}
|
||||
</Text>
|
||||
)
|
||||
i++
|
||||
|
||||
while (i < lines.length) {
|
||||
const def = lines[i]!.match(DEF_RE)
|
||||
|
||||
if (!def) {
|
||||
break
|
||||
}
|
||||
|
||||
nodes.push(
|
||||
<Text key={`${key}-def-${i}`}>
|
||||
<Text color={t.color.dim}> · </Text>
|
||||
<MdInline t={t} text={def[1]!} />
|
||||
</Text>
|
||||
)
|
||||
i++
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const bullet = line.match(/^(\s*)[-+*]\s+(.*)$/)
|
||||
|
||||
if (bullet) {
|
||||
start('list')
|
||||
const depth = indentDepth(bullet[1]!)
|
||||
const task = bullet[2]!.match(/^\[( |x|X)\]\s+(.*)$/)
|
||||
const marker = task ? (task[1]!.toLowerCase() === 'x' ? '☑' : '☐') : '•'
|
||||
const body = task ? task[2]! : bullet[2]!
|
||||
|
||||
nodes.push(
|
||||
<Text key={key}>
|
||||
<Text color={t.color.dim}>
|
||||
{' '.repeat(depth * 2)}
|
||||
{marker}{' '}
|
||||
</Text>
|
||||
<MdInline t={t} text={body} />
|
||||
</Text>
|
||||
)
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const numbered = line.match(/^(\s*)(\d+)[.)]\s+(.*)$/)
|
||||
|
||||
if (numbered) {
|
||||
start('list')
|
||||
const depth = indentDepth(numbered[1]!)
|
||||
|
||||
nodes.push(
|
||||
<Text key={key}>
|
||||
<Text color={t.color.dim}>
|
||||
{' '.repeat(depth * 2)}
|
||||
{numbered[2]}.{' '}
|
||||
</Text>
|
||||
<MdInline t={t} text={numbered[3]!} />
|
||||
</Text>
|
||||
)
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^\s*(?:>\s*)+/.test(line)) {
|
||||
start('quote')
|
||||
const quoteLines: Array<{ depth: number; text: string }> = []
|
||||
|
||||
while (i < lines.length && /^\s*(?:>\s*)+/.test(lines[i]!)) {
|
||||
const raw = lines[i]!
|
||||
const prefix = raw.match(/^\s*(?:>\s*)+/)?.[0] ?? ''
|
||||
|
||||
quoteLines.push({
|
||||
depth: (prefix.match(/>/g) ?? []).length,
|
||||
text: raw.slice(prefix.length)
|
||||
})
|
||||
i++
|
||||
}
|
||||
|
||||
nodes.push(
|
||||
<Box flexDirection="column" key={key}>
|
||||
{quoteLines.map((ql, qi) => (
|
||||
<Text color={t.color.dim} key={qi}>
|
||||
{' '.repeat(Math.max(0, ql.depth - 1) * 2)}
|
||||
{'│ '}
|
||||
<MdInline t={t} text={ql.text} />
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
if (line.includes('|') && i + 1 < lines.length && isTableDivider(lines[i + 1]!)) {
|
||||
start('table')
|
||||
const tableRows: string[][] = []
|
||||
|
||||
if (i + 1 < lines.length && DEF_RE.test(lines[i + 1]!)) {
|
||||
start('list')
|
||||
nodes.push(
|
||||
<Text bold key={key}>
|
||||
{line.trim()}
|
||||
</Text>
|
||||
)
|
||||
i++
|
||||
tableRows.push(splitTableRow(line))
|
||||
i += 2
|
||||
|
||||
while (i < lines.length) {
|
||||
const def = lines[i]!.match(DEF_RE)
|
||||
|
||||
if (!def) {
|
||||
break
|
||||
while (i < lines.length && lines[i]!.includes('|') && lines[i]!.trim()) {
|
||||
tableRows.push(splitTableRow(lines[i]!))
|
||||
i++
|
||||
}
|
||||
|
||||
nodes.push(renderTable(key, tableRows, t))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^<details\b/i.test(line) || /^<\/details>/i.test(line)) {
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const summary = line.match(/^<summary>(.*?)<\/summary>$/i)
|
||||
|
||||
if (summary) {
|
||||
start('paragraph')
|
||||
nodes.push(
|
||||
<Text key={`${key}-def-${i}`}>
|
||||
<Text color={t.color.dim}> · </Text>
|
||||
<MdInline t={t} text={def[1]!} />
|
||||
<Text color={t.color.dim} key={key}>
|
||||
▶ {summary[1]}
|
||||
</Text>
|
||||
)
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const bullet = line.match(/^(\s*)[-+*]\s+(.*)$/)
|
||||
|
||||
if (bullet) {
|
||||
start('list')
|
||||
const depth = indentDepth(bullet[1]!)
|
||||
const task = bullet[2]!.match(/^\[( |x|X)\]\s+(.*)$/)
|
||||
const marker = task ? (task[1]!.toLowerCase() === 'x' ? '☑' : '☐') : '•'
|
||||
const body = task ? task[2]! : bullet[2]!
|
||||
|
||||
nodes.push(
|
||||
<Text key={key}>
|
||||
<Text color={t.color.dim}>
|
||||
{' '.repeat(depth * 2)}
|
||||
{marker}{' '}
|
||||
if (/^<\/?[^>]+>$/.test(line.trim())) {
|
||||
start('paragraph')
|
||||
nodes.push(
|
||||
<Text color={t.color.dim} key={key}>
|
||||
{line.trim()}
|
||||
</Text>
|
||||
<MdInline t={t} text={body} />
|
||||
</Text>
|
||||
)
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const numbered = line.match(/^(\s*)(\d+)[.)]\s+(.*)$/)
|
||||
|
||||
if (numbered) {
|
||||
start('list')
|
||||
const depth = indentDepth(numbered[1]!)
|
||||
|
||||
nodes.push(
|
||||
<Text key={key}>
|
||||
<Text color={t.color.dim}>
|
||||
{' '.repeat(depth * 2)}
|
||||
{numbered[2]}.{' '}
|
||||
</Text>
|
||||
<MdInline t={t} text={numbered[3]!} />
|
||||
</Text>
|
||||
)
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^\s*(?:>\s*)+/.test(line)) {
|
||||
start('quote')
|
||||
const quoteLines: Array<{ depth: number; text: string }> = []
|
||||
|
||||
while (i < lines.length && /^\s*(?:>\s*)+/.test(lines[i]!)) {
|
||||
const raw = lines[i]!
|
||||
const prefix = raw.match(/^\s*(?:>\s*)+/)?.[0] ?? ''
|
||||
|
||||
quoteLines.push({
|
||||
depth: (prefix.match(/>/g) ?? []).length,
|
||||
text: raw.slice(prefix.length)
|
||||
})
|
||||
)
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
nodes.push(
|
||||
<Box flexDirection="column" key={key}>
|
||||
{quoteLines.map((ql, qi) => (
|
||||
<Text color={t.color.dim} key={qi}>
|
||||
{' '.repeat(Math.max(0, ql.depth - 1) * 2)}
|
||||
{'│ '}
|
||||
<MdInline t={t} text={ql.text} />
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
if (line.includes('|') && line.trim().startsWith('|')) {
|
||||
start('table')
|
||||
const tableRows: string[][] = []
|
||||
|
||||
continue
|
||||
}
|
||||
while (i < lines.length && lines[i]!.trim().startsWith('|')) {
|
||||
const row = lines[i]!.trim()
|
||||
|
||||
if (line.includes('|') && i + 1 < lines.length && isTableDivider(lines[i + 1]!)) {
|
||||
start('table')
|
||||
const tableRows: string[][] = []
|
||||
if (!/^[|\s:-]+$/.test(row)) {
|
||||
tableRows.push(splitTableRow(row))
|
||||
}
|
||||
|
||||
tableRows.push(splitTableRow(line))
|
||||
i += 2
|
||||
|
||||
while (i < lines.length && lines[i]!.includes('|') && lines[i]!.trim()) {
|
||||
tableRows.push(splitTableRow(lines[i]!))
|
||||
i++
|
||||
}
|
||||
|
||||
nodes.push(renderTable(key, tableRows, t))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^<details\b/i.test(line) || /^<\/details>/i.test(line)) {
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const summary = line.match(/^<summary>(.*?)<\/summary>$/i)
|
||||
|
||||
if (summary) {
|
||||
start('paragraph')
|
||||
nodes.push(
|
||||
<Text color={t.color.dim} key={key}>
|
||||
▶ {summary[1]}
|
||||
</Text>
|
||||
)
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^<\/?[^>]+>$/.test(line.trim())) {
|
||||
start('paragraph')
|
||||
nodes.push(
|
||||
<Text color={t.color.dim} key={key}>
|
||||
{line.trim()}
|
||||
</Text>
|
||||
)
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.includes('|') && line.trim().startsWith('|')) {
|
||||
start('table')
|
||||
const tableRows: string[][] = []
|
||||
|
||||
while (i < lines.length && lines[i]!.trim().startsWith('|')) {
|
||||
const row = lines[i]!.trim()
|
||||
|
||||
if (!/^[|\s:-]+$/.test(row)) {
|
||||
tableRows.push(splitTableRow(row))
|
||||
i++
|
||||
}
|
||||
|
||||
i++
|
||||
if (tableRows.length) {
|
||||
nodes.push(renderTable(key, tableRows, t))
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (tableRows.length) {
|
||||
nodes.push(renderTable(key, tableRows, t))
|
||||
}
|
||||
start('paragraph')
|
||||
nodes.push(<MdInline key={key} t={t} text={line} />)
|
||||
|
||||
continue
|
||||
i++
|
||||
}
|
||||
|
||||
start('paragraph')
|
||||
nodes.push(<MdInline key={key} t={t} text={line} />)
|
||||
|
||||
i++
|
||||
}
|
||||
return nodes
|
||||
}, [compact, t, text])
|
||||
|
||||
return <Box flexDirection="column">{nodes}</Box>
|
||||
}
|
||||
|
||||
export const Md = memo(MdImpl)
|
||||
|
|
|
|||
|
|
@ -2,18 +2,10 @@ import { Box, Text, useInput } from '@hermes/ink'
|
|||
import { useEffect, useState } from 'react'
|
||||
|
||||
import type { GatewayClient } from '../gatewayClient.js'
|
||||
import type { ModelOptionProvider, ModelOptionsResponse } from '../gatewayTypes.js'
|
||||
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
|
||||
interface ProviderItem {
|
||||
is_current?: boolean
|
||||
models?: string[]
|
||||
name: string
|
||||
slug: string
|
||||
total_models?: number
|
||||
warning?: string
|
||||
}
|
||||
|
||||
const VISIBLE = 12
|
||||
|
||||
const pageOffset = (count: number, sel: number) => Math.max(0, Math.min(sel - Math.floor(VISIBLE / 2), count - VISIBLE))
|
||||
|
|
@ -31,7 +23,7 @@ export function ModelPicker({
|
|||
sessionId: string | null
|
||||
t: Theme
|
||||
}) {
|
||||
const [providers, setProviders] = useState<ProviderItem[]>([])
|
||||
const [providers, setProviders] = useState<ModelOptionProvider[]>([])
|
||||
const [currentModel, setCurrentModel] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
|
@ -41,9 +33,9 @@ export function ModelPicker({
|
|||
const [stage, setStage] = useState<'model' | 'provider'>('provider')
|
||||
|
||||
useEffect(() => {
|
||||
gw.request('model.options', sessionId ? { session_id: sessionId } : {})
|
||||
.then((raw: any) => {
|
||||
const r = asRpcResult(raw)
|
||||
gw.request<ModelOptionsResponse>('model.options', sessionId ? { session_id: sessionId } : {})
|
||||
.then(raw => {
|
||||
const r = asRpcResult<ModelOptionsResponse>(raw)
|
||||
|
||||
if (!r) {
|
||||
setErr('invalid response: model.options')
|
||||
|
|
@ -52,7 +44,7 @@ export function ModelPicker({
|
|||
return
|
||||
}
|
||||
|
||||
const next = (r.providers ?? []) as ProviderItem[]
|
||||
const next = r.providers ?? []
|
||||
setProviders(next)
|
||||
setCurrentModel(String(r.model ?? ''))
|
||||
setProviderIdx(
|
||||
|
|
|
|||
|
|
@ -2,18 +2,10 @@ import { Box, Text, useInput } from '@hermes/ink'
|
|||
import { useEffect, useState } from 'react'
|
||||
|
||||
import type { GatewayClient } from '../gatewayClient.js'
|
||||
import type { SessionListItem, SessionListResponse } from '../gatewayTypes.js'
|
||||
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
|
||||
interface SessionItem {
|
||||
id: string
|
||||
title: string
|
||||
preview: string
|
||||
started_at: number
|
||||
message_count: number
|
||||
source?: string
|
||||
}
|
||||
|
||||
function age(ts: number): string {
|
||||
const d = (Date.now() / 1000 - ts) / 86400
|
||||
|
||||
|
|
@ -41,15 +33,15 @@ export function SessionPicker({
|
|||
onSelect: (id: string) => void
|
||||
t: Theme
|
||||
}) {
|
||||
const [items, setItems] = useState<SessionItem[]>([])
|
||||
const [items, setItems] = useState<SessionListItem[]>([])
|
||||
const [err, setErr] = useState('')
|
||||
const [sel, setSel] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
gw.request('session.list', { limit: 20 })
|
||||
.then((raw: any) => {
|
||||
const r = asRpcResult(raw)
|
||||
gw.request<SessionListResponse>('session.list', { limit: 20 })
|
||||
.then(raw => {
|
||||
const r = asRpcResult<SessionListResponse>(raw)
|
||||
|
||||
if (!r) {
|
||||
setErr('invalid response: session.list')
|
||||
|
|
@ -58,7 +50,7 @@ export function SessionPicker({
|
|||
return
|
||||
}
|
||||
|
||||
setItems((r?.sessions ?? []) as SessionItem[])
|
||||
setItems(r.sessions ?? [])
|
||||
setErr('')
|
||||
setLoading(false)
|
||||
})
|
||||
|
|
|
|||
15
ui-tui/src/components/sidebarRail.tsx
Normal file
15
ui-tui/src/components/sidebarRail.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { Box, NoSelect } from '@hermes/ink'
|
||||
|
||||
import type { Theme } from '../theme.js'
|
||||
import type { WidgetSpec } from '../widgets.js'
|
||||
import { WidgetHost } from '../widgets.js'
|
||||
|
||||
export function SidebarRail({ t, widgets, width }: { t: Theme; widgets: WidgetSpec[]; width: number }) {
|
||||
return (
|
||||
<NoSelect flexDirection="column" flexShrink={0} width={width}>
|
||||
<Box borderColor={t.color.bronze as any} borderStyle="round" flexDirection="column" paddingX={2} paddingY={1}>
|
||||
<WidgetHost region="sidebar" widgets={widgets} />
|
||||
</Box>
|
||||
</NoSelect>
|
||||
)
|
||||
}
|
||||
|
|
@ -29,8 +29,16 @@ const dim = (s: string) => DIM + s + DIM_OFF
|
|||
|
||||
let _seg: Intl.Segmenter | null = null
|
||||
const seg = () => (_seg ??= new Intl.Segmenter(undefined, { granularity: 'grapheme' }))
|
||||
const STOP_CACHE_MAX = 32
|
||||
const stopCache = new Map<string, number[]>()
|
||||
|
||||
function graphemeStops(s: string) {
|
||||
const hit = stopCache.get(s)
|
||||
|
||||
if (hit) {
|
||||
return hit
|
||||
}
|
||||
|
||||
const stops = [0]
|
||||
|
||||
for (const { index } of seg().segment(s)) {
|
||||
|
|
@ -43,6 +51,16 @@ function graphemeStops(s: string) {
|
|||
stops.push(s.length)
|
||||
}
|
||||
|
||||
stopCache.set(s, stops)
|
||||
|
||||
if (stopCache.size > STOP_CACHE_MAX) {
|
||||
const oldest = stopCache.keys().next().value
|
||||
|
||||
if (oldest !== undefined) {
|
||||
stopCache.delete(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
return stops
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { memo, type ReactNode, useEffect, useMemo, useState } from 'react'
|
|||
import spinners, { type BrailleSpinnerName } from 'unicode-animations'
|
||||
|
||||
import {
|
||||
compactPreview,
|
||||
estimateTokensRough,
|
||||
fmtK,
|
||||
formatToolCall,
|
||||
|
|
@ -13,7 +14,7 @@ import {
|
|||
toolTrailLabel
|
||||
} from '../lib/text.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
import type { ActiveTool, ActivityItem, DetailsMode, ThinkingMode } from '../types.js'
|
||||
import type { ActiveTool, ActivityItem, DetailsMode, SubagentProgress, ThinkingMode } from '../types.js'
|
||||
|
||||
const THINK: BrailleSpinnerName[] = ['helix', 'breathe', 'orbit', 'dna', 'waverows', 'snake', 'pulse']
|
||||
const TOOL: BrailleSpinnerName[] = ['cascade', 'scan', 'diagswipe', 'fillsweep', 'rain', 'columns', 'sparkle']
|
||||
|
|
@ -128,6 +129,122 @@ function Chevron({
|
|||
)
|
||||
}
|
||||
|
||||
function SubagentAccordion({ expanded, item, t }: { expanded: boolean; item: SubagentProgress; t: Theme }) {
|
||||
const [open, setOpen] = useState(expanded)
|
||||
const [openThinking, setOpenThinking] = useState(expanded)
|
||||
const [openTools, setOpenTools] = useState(expanded)
|
||||
const [openNotes, setOpenNotes] = useState(expanded)
|
||||
|
||||
useEffect(() => {
|
||||
if (!expanded) {
|
||||
return
|
||||
}
|
||||
|
||||
setOpen(true)
|
||||
setOpenThinking(true)
|
||||
setOpenTools(true)
|
||||
setOpenNotes(true)
|
||||
}, [expanded])
|
||||
|
||||
const statusTone: 'dim' | 'error' | 'warn' =
|
||||
item.status === 'failed' ? 'error' : item.status === 'interrupted' ? 'warn' : 'dim'
|
||||
|
||||
const prefix = item.taskCount > 1 ? `[${item.index + 1}/${item.taskCount}] ` : ''
|
||||
const title = `${prefix}${item.goal || `Subagent ${item.index + 1}`}`
|
||||
const summary = compactPreview((item.summary || '').replace(/\s+/g, ' ').trim(), 72)
|
||||
|
||||
const suffix =
|
||||
item.status === 'running'
|
||||
? 'running'
|
||||
: `${item.status}${item.durationSeconds ? ` · ${fmtElapsed(item.durationSeconds * 1000)}` : ''}`
|
||||
|
||||
const thinkingText = item.thinking.join('\n')
|
||||
const hasThinking = Boolean(thinkingText)
|
||||
const hasTools = item.tools.length > 0
|
||||
const noteRows = [...(summary ? [summary] : []), ...item.notes]
|
||||
const hasNotes = noteRows.length > 0
|
||||
const active = expanded || open
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingLeft={1}>
|
||||
<Chevron onClick={() => setOpen(v => !v)} open={active} suffix={suffix} t={t} title={title} tone={statusTone} />
|
||||
{active && (
|
||||
<Box flexDirection="column" paddingLeft={2}>
|
||||
{hasThinking && (
|
||||
<>
|
||||
<Chevron
|
||||
count={item.thinking.length}
|
||||
onClick={() => setOpenThinking(v => !v)}
|
||||
open={expanded || openThinking}
|
||||
t={t}
|
||||
title="Thinking"
|
||||
/>
|
||||
{(expanded || openThinking) && (
|
||||
<Thinking
|
||||
active={item.status === 'running'}
|
||||
mode="full"
|
||||
reasoning={thinkingText}
|
||||
streaming={item.status === 'running'}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasTools && (
|
||||
<>
|
||||
<Chevron
|
||||
count={item.tools.length}
|
||||
onClick={() => setOpenTools(v => !v)}
|
||||
open={expanded || openTools}
|
||||
t={t}
|
||||
title="Tool calls"
|
||||
/>
|
||||
{(expanded || openTools) && (
|
||||
<Box flexDirection="column">
|
||||
{item.tools.map((line, index) => (
|
||||
<Text color={t.color.cornsilk} key={`${item.id}-tool-${index}`} wrap="wrap-trim">
|
||||
<Text color={t.color.amber}>● </Text>
|
||||
{line}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasNotes && (
|
||||
<>
|
||||
<Chevron
|
||||
count={noteRows.length}
|
||||
onClick={() => setOpenNotes(v => !v)}
|
||||
open={expanded || openNotes}
|
||||
t={t}
|
||||
title="Progress"
|
||||
tone={statusTone}
|
||||
/>
|
||||
{(expanded || openNotes) && (
|
||||
<Box flexDirection="column">
|
||||
{noteRows.map((line, index) => (
|
||||
<Text
|
||||
color={statusTone === 'error' ? t.color.error : t.color.dim}
|
||||
dimColor
|
||||
key={`${item.id}-note-${index}`}
|
||||
>
|
||||
<Text dimColor>{index === noteRows.length - 1 ? '└ ' : '├ '}</Text>
|
||||
{line}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Thinking ─────────────────────────────────────────────────────────
|
||||
|
||||
export const Thinking = memo(function Thinking({
|
||||
|
|
@ -143,7 +260,7 @@ export const Thinking = memo(function Thinking({
|
|||
streaming?: boolean
|
||||
t: Theme
|
||||
}) {
|
||||
const preview = thinkingPreview(reasoning, mode, THINKING_COT_MAX)
|
||||
const preview = useMemo(() => thinkingPreview(reasoning, mode, THINKING_COT_MAX), [mode, reasoning])
|
||||
const lines = useMemo(() => preview.split('\n').map(line => line.replace(/\t/g, ' ')), [preview])
|
||||
|
||||
return (
|
||||
|
|
@ -198,6 +315,7 @@ export const ToolTrail = memo(function ToolTrail({
|
|||
reasoning = '',
|
||||
reasoningTokens,
|
||||
reasoningStreaming = false,
|
||||
subagents = [],
|
||||
t,
|
||||
tools = [],
|
||||
toolTokens,
|
||||
|
|
@ -210,6 +328,7 @@ export const ToolTrail = memo(function ToolTrail({
|
|||
reasoning?: string
|
||||
reasoningTokens?: number
|
||||
reasoningStreaming?: boolean
|
||||
subagents?: SubagentProgress[]
|
||||
t: Theme
|
||||
tools?: ActiveTool[]
|
||||
toolTokens?: number
|
||||
|
|
@ -219,6 +338,7 @@ export const ToolTrail = memo(function ToolTrail({
|
|||
const [now, setNow] = useState(() => Date.now())
|
||||
const [openThinking, setOpenThinking] = useState(false)
|
||||
const [openTools, setOpenTools] = useState(false)
|
||||
const [openSubagents, setOpenSubagents] = useState(false)
|
||||
const [openMeta, setOpenMeta] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -235,19 +355,21 @@ export const ToolTrail = memo(function ToolTrail({
|
|||
if (detailsMode === 'expanded') {
|
||||
setOpenThinking(true)
|
||||
setOpenTools(true)
|
||||
setOpenSubagents(true)
|
||||
setOpenMeta(true)
|
||||
}
|
||||
|
||||
if (detailsMode === 'hidden') {
|
||||
setOpenThinking(false)
|
||||
setOpenTools(false)
|
||||
setOpenSubagents(false)
|
||||
setOpenMeta(false)
|
||||
}
|
||||
}, [detailsMode])
|
||||
|
||||
const cot = thinkingPreview(reasoning, 'full', THINKING_COT_MAX)
|
||||
const cot = useMemo(() => thinkingPreview(reasoning, 'full', THINKING_COT_MAX), [reasoning])
|
||||
|
||||
if (!busy && !trail.length && !tools.length && !activity.length && !cot && !reasoningActive) {
|
||||
if (!busy && !trail.length && !tools.length && !subagents.length && !activity.length && !cot && !reasoningActive) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
|
@ -334,6 +456,7 @@ export const ToolTrail = memo(function ToolTrail({
|
|||
// ── Derived ────────────────────────────────────────────────────
|
||||
|
||||
const hasTools = groups.length > 0
|
||||
const hasSubagents = subagents.length > 0
|
||||
const hasMeta = meta.length > 0
|
||||
const hasThinking = !!cot || reasoningActive || (busy && !hasTools)
|
||||
const thinkingLive = reasoningActive || reasoningStreaming
|
||||
|
|
@ -395,6 +518,10 @@ export const ToolTrail = memo(function ToolTrail({
|
|||
))
|
||||
: null
|
||||
|
||||
const subagentBlock = hasSubagents
|
||||
? subagents.map(item => <SubagentAccordion expanded={detailsMode === 'expanded'} item={item} key={item.id} t={t} />)
|
||||
: null
|
||||
|
||||
const metaBlock = hasMeta
|
||||
? meta.map((row, i) => (
|
||||
<Text color={row.color} dimColor={row.dimColor} key={row.key}>
|
||||
|
|
@ -418,6 +545,7 @@ export const ToolTrail = memo(function ToolTrail({
|
|||
<Box flexDirection="column">
|
||||
{thinkingBlock}
|
||||
{toolBlock}
|
||||
{subagentBlock}
|
||||
{metaBlock}
|
||||
{totalBlock}
|
||||
</Box>
|
||||
|
|
@ -468,6 +596,19 @@ export const ToolTrail = memo(function ToolTrail({
|
|||
</>
|
||||
)}
|
||||
|
||||
{hasSubagents && (
|
||||
<>
|
||||
<Chevron
|
||||
count={subagents.length}
|
||||
onClick={() => setOpenSubagents(v => !v)}
|
||||
open={openSubagents}
|
||||
t={t}
|
||||
title="Subagents"
|
||||
/>
|
||||
{openSubagents && subagentBlock}
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasMeta && (
|
||||
<>
|
||||
<Chevron
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { existsSync } from 'node:fs'
|
|||
import { delimiter, resolve } from 'node:path'
|
||||
import { createInterface } from 'node:readline'
|
||||
|
||||
import type { GatewayEvent } from './gatewayTypes.js'
|
||||
|
||||
const MAX_GATEWAY_LOG_LINES = 200
|
||||
const MAX_LOG_PREVIEW = 240
|
||||
const STARTUP_TIMEOUT_MS = Math.max(5000, parseInt(process.env.HERMES_TUI_STARTUP_TIMEOUT_MS ?? '15000', 10) || 15000)
|
||||
|
|
@ -42,10 +44,12 @@ const resolvePython = (root: string) => {
|
|||
return process.platform === 'win32' ? 'python' : 'python3'
|
||||
}
|
||||
|
||||
export interface GatewayEvent {
|
||||
type: string
|
||||
session_id?: string
|
||||
payload?: Record<string, unknown>
|
||||
const asGatewayEvent = (value: unknown): GatewayEvent | null => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return typeof (value as { type?: unknown }).type === 'string' ? (value as GatewayEvent) : null
|
||||
}
|
||||
|
||||
interface Pending {
|
||||
|
|
@ -174,13 +178,23 @@ export class GatewayClient extends EventEmitter {
|
|||
|
||||
if (p) {
|
||||
this.pending.delete(id!)
|
||||
msg.error ? p.reject(new Error((msg.error as any).message)) : p.resolve(msg.result)
|
||||
|
||||
if (msg.error) {
|
||||
const err = msg.error as { message?: unknown } | null | undefined
|
||||
p.reject(new Error(typeof err?.message === 'string' ? err.message : 'request failed'))
|
||||
} else {
|
||||
p.resolve(msg.result)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (msg.method === 'event') {
|
||||
this.publish(msg.params as GatewayEvent)
|
||||
const ev = asGatewayEvent(msg.params)
|
||||
|
||||
if (ev) {
|
||||
this.publish(ev)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -218,7 +232,7 @@ export class GatewayClient extends EventEmitter {
|
|||
return this.logs.slice(-Math.max(1, limit)).join('\n')
|
||||
}
|
||||
|
||||
request(method: string, params: Record<string, unknown> = {}): Promise<unknown> {
|
||||
request<T = unknown>(method: string, params: Record<string, unknown> = {}): Promise<T> {
|
||||
if (!this.proc?.stdin || this.proc.killed || this.proc.exitCode !== null) {
|
||||
this.start()
|
||||
}
|
||||
|
|
@ -243,7 +257,7 @@ export class GatewayClient extends EventEmitter {
|
|||
},
|
||||
resolve: v => {
|
||||
clearTimeout(timeout)
|
||||
resolve(v)
|
||||
resolve(v as T)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
198
ui-tui/src/gatewayTypes.ts
Normal file
198
ui-tui/src/gatewayTypes.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import type { SessionInfo, SlashCategory, Usage } from './types.js'
|
||||
|
||||
export interface GatewaySkin {
|
||||
banner_hero?: string
|
||||
banner_logo?: string
|
||||
branding?: Record<string, string>
|
||||
colors?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface GatewayCompletionItem {
|
||||
display: string
|
||||
meta?: string
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface GatewayTranscriptMessage {
|
||||
context?: string
|
||||
name?: string
|
||||
role: 'assistant' | 'system' | 'tool' | 'user'
|
||||
text?: string
|
||||
}
|
||||
|
||||
export interface CommandsCatalogResponse {
|
||||
canon?: Record<string, string>
|
||||
categories?: SlashCategory[]
|
||||
pairs?: [string, string][]
|
||||
skill_count?: number
|
||||
sub?: Record<string, string[]>
|
||||
warning?: string
|
||||
}
|
||||
|
||||
export interface CompletionResponse {
|
||||
items?: GatewayCompletionItem[]
|
||||
replace_from?: number
|
||||
}
|
||||
|
||||
export interface ConfigDisplayConfig {
|
||||
bell_on_complete?: boolean
|
||||
details_mode?: string
|
||||
thinking_mode?: string
|
||||
tui_compact?: boolean
|
||||
tui_statusbar?: boolean
|
||||
}
|
||||
|
||||
export interface ConfigFullResponse {
|
||||
config?: {
|
||||
display?: ConfigDisplayConfig
|
||||
}
|
||||
}
|
||||
|
||||
export interface ConfigMtimeResponse {
|
||||
mtime?: number
|
||||
}
|
||||
|
||||
export interface BackgroundStartResponse {
|
||||
task_id?: string
|
||||
}
|
||||
|
||||
export interface SessionCreateResponse {
|
||||
info?: SessionInfo & { credential_warning?: string }
|
||||
session_id: string
|
||||
}
|
||||
|
||||
export interface SessionResumeResponse {
|
||||
info?: SessionInfo
|
||||
message_count?: number
|
||||
messages: GatewayTranscriptMessage[]
|
||||
resumed?: string
|
||||
session_id: string
|
||||
}
|
||||
|
||||
export interface SessionListItem {
|
||||
id: string
|
||||
message_count: number
|
||||
preview: string
|
||||
source?: string
|
||||
started_at: number
|
||||
title: string
|
||||
}
|
||||
|
||||
export interface SessionListResponse {
|
||||
sessions?: SessionListItem[]
|
||||
}
|
||||
|
||||
export interface SessionUndoResponse {
|
||||
removed?: number
|
||||
}
|
||||
|
||||
export interface SessionHistoryResponse {
|
||||
count?: number
|
||||
messages?: GatewayTranscriptMessage[]
|
||||
}
|
||||
|
||||
export interface ModelOptionProvider {
|
||||
is_current?: boolean
|
||||
models?: string[]
|
||||
name: string
|
||||
slug: string
|
||||
total_models?: number
|
||||
warning?: string
|
||||
}
|
||||
|
||||
export interface ModelOptionsResponse {
|
||||
model?: string
|
||||
provider?: string
|
||||
providers?: ModelOptionProvider[]
|
||||
}
|
||||
|
||||
export interface ToolsetDetails {
|
||||
description: string
|
||||
enabled: boolean
|
||||
name: string
|
||||
tool_count: number
|
||||
tools: string[]
|
||||
}
|
||||
|
||||
export interface ToolsListResponse {
|
||||
toolsets?: ToolsetDetails[]
|
||||
}
|
||||
|
||||
export interface ToolSummary {
|
||||
description: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface ToolsShowSection {
|
||||
name: string
|
||||
tools: ToolSummary[]
|
||||
}
|
||||
|
||||
export interface ToolsShowResponse {
|
||||
sections?: ToolsShowSection[]
|
||||
total?: number
|
||||
}
|
||||
|
||||
export interface ToolsConfigureResponse {
|
||||
changed?: string[]
|
||||
enabled_toolsets?: string[]
|
||||
info?: SessionInfo
|
||||
missing_servers?: string[]
|
||||
reset?: boolean
|
||||
unknown?: string[]
|
||||
}
|
||||
|
||||
export interface SlashExecResponse {
|
||||
output?: string
|
||||
warning?: string
|
||||
}
|
||||
|
||||
export interface SubagentEventPayload {
|
||||
duration_seconds?: number
|
||||
goal: string
|
||||
status?: 'completed' | 'failed' | 'interrupted' | 'running'
|
||||
summary?: string
|
||||
task_count?: number
|
||||
task_index: number
|
||||
text?: string
|
||||
tool_name?: string
|
||||
tool_preview?: string
|
||||
}
|
||||
|
||||
export type GatewayEvent =
|
||||
| { payload?: { skin?: GatewaySkin }; session_id?: string; type: 'gateway.ready' }
|
||||
| { payload?: GatewaySkin; session_id?: string; type: 'skin.changed' }
|
||||
| { payload: SessionInfo; session_id?: string; type: 'session.info' }
|
||||
| { payload?: { text?: string }; session_id?: string; type: 'thinking.delta' }
|
||||
| { payload?: undefined; session_id?: string; type: 'message.start' }
|
||||
| { payload?: { kind?: string; text?: string }; session_id?: string; type: 'status.update' }
|
||||
| { payload: { line: string }; session_id?: string; type: 'gateway.stderr' }
|
||||
| { payload?: { cwd?: string; python?: string }; session_id?: string; type: 'gateway.start_timeout' }
|
||||
| { payload?: { preview?: string }; session_id?: string; type: 'gateway.protocol_error' }
|
||||
| { payload?: { text?: string }; session_id?: string; type: 'reasoning.delta' | 'reasoning.available' }
|
||||
| { payload: { name?: string; preview?: string }; session_id?: string; type: 'tool.progress' }
|
||||
| { payload: { name?: string }; session_id?: string; type: 'tool.generating' }
|
||||
| { payload: { context?: string; name?: string; tool_id: string }; session_id?: string; type: 'tool.start' }
|
||||
| {
|
||||
payload: { error?: string; inline_diff?: string; name?: string; summary?: string; tool_id: string }
|
||||
session_id?: string
|
||||
type: 'tool.complete'
|
||||
}
|
||||
| {
|
||||
payload: { choices: string[] | null; question: string; request_id: string }
|
||||
session_id?: string
|
||||
type: 'clarify.request'
|
||||
}
|
||||
| { payload: { command: string; description: string }; session_id?: string; type: 'approval.request' }
|
||||
| { payload: { request_id: string }; session_id?: string; type: 'sudo.request' }
|
||||
| { payload: { env_var: string; prompt: string; request_id: string }; session_id?: string; type: 'secret.request' }
|
||||
| { payload: { task_id: string; text: string }; session_id?: string; type: 'background.complete' }
|
||||
| { payload: { text: string }; session_id?: string; type: 'btw.complete' }
|
||||
| { payload: SubagentEventPayload; session_id?: string; type: 'subagent.start' }
|
||||
| { payload: SubagentEventPayload; session_id?: string; type: 'subagent.thinking' }
|
||||
| { payload: SubagentEventPayload; session_id?: string; type: 'subagent.tool' }
|
||||
| { payload: SubagentEventPayload; session_id?: string; type: 'subagent.progress' }
|
||||
| { payload: SubagentEventPayload; session_id?: string; type: 'subagent.complete' }
|
||||
| { payload: { rendered?: string; text?: string }; session_id?: string; type: 'message.delta' }
|
||||
| { payload?: { rendered?: string; text?: string; usage?: Usage }; session_id?: string; type: 'message.complete' }
|
||||
| { payload?: { message?: string }; session_id?: string; type: 'error' }
|
||||
|
|
@ -2,14 +2,10 @@ import { useEffect, useRef, useState } from 'react'
|
|||
|
||||
import type { CompletionItem } from '../app/interfaces.js'
|
||||
import type { GatewayClient } from '../gatewayClient.js'
|
||||
|
||||
import type { CompletionResponse } from '../gatewayTypes.js'
|
||||
import { asRpcResult } from '../lib/rpc.js'
|
||||
const TAB_PATH_RE = /((?:["']?(?:[A-Za-z]:[\\/]|\.{1,2}\/|~\/|\/|@|[^"'`\s]+\/))[^\s]*)$/
|
||||
|
||||
interface CompletionResult {
|
||||
items?: CompletionItem[]
|
||||
replace_from?: number
|
||||
}
|
||||
|
||||
export function useCompletion(input: string, blocked: boolean, gw: GatewayClient) {
|
||||
const [completions, setCompletions] = useState<CompletionItem[]>([])
|
||||
const [compIdx, setCompIdx] = useState(0)
|
||||
|
|
@ -51,12 +47,12 @@ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient
|
|||
}
|
||||
|
||||
const req = isSlash
|
||||
? gw.request('complete.slash', { text: input })
|
||||
: gw.request('complete.path', { word: pathWord })
|
||||
? gw.request<CompletionResponse>('complete.slash', { text: input })
|
||||
: gw.request<CompletionResponse>('complete.path', { word: pathWord })
|
||||
|
||||
req
|
||||
.then(raw => {
|
||||
const r = raw as CompletionResult | null | undefined
|
||||
const r = asRpcResult<CompletionResponse>(raw)
|
||||
|
||||
if (ref.current !== input) {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
export type RpcResult = Record<string, any>
|
||||
|
||||
export const asRpcResult = (value: unknown): RpcResult | null => {
|
||||
export const asRpcResult = <T extends RpcResult = RpcResult>(value: unknown): T | null => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return value as RpcResult
|
||||
return value as T
|
||||
}
|
||||
|
||||
export const rpcErrorMessage = (err: unknown) => {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,19 @@ export interface ActivityItem {
|
|||
tone: 'error' | 'info' | 'warn'
|
||||
}
|
||||
|
||||
export interface SubagentProgress {
|
||||
durationSeconds?: number
|
||||
goal: string
|
||||
id: string
|
||||
index: number
|
||||
notes: string[]
|
||||
status: 'completed' | 'failed' | 'interrupted' | 'running'
|
||||
summary?: string
|
||||
taskCount: number
|
||||
thinking: string[]
|
||||
tools: string[]
|
||||
}
|
||||
|
||||
export interface ApprovalReq {
|
||||
command: string
|
||||
description: string
|
||||
|
|
|
|||
576
ui-tui/src/widgets.tsx
Normal file
576
ui-tui/src/widgets.tsx
Normal file
|
|
@ -0,0 +1,576 @@
|
|||
import { Box, Text } from '@hermes/ink'
|
||||
import { Fragment, type ReactNode, useEffect, useState } from 'react'
|
||||
|
||||
import type { Theme } from './theme.js'
|
||||
import type { Usage } from './types.js'
|
||||
|
||||
// ── Region types ──────────────────────────────────────────────────────
|
||||
export const WIDGET_REGIONS = [
|
||||
'transcript-header',
|
||||
'transcript-inline',
|
||||
'transcript-tail',
|
||||
'dock',
|
||||
'overlay',
|
||||
'sidebar'
|
||||
] as const
|
||||
export type WidgetRegion = (typeof WIDGET_REGIONS)[number]
|
||||
|
||||
export interface WidgetCtx {
|
||||
blocked?: boolean
|
||||
bgCount: number
|
||||
busy: boolean
|
||||
cols: number
|
||||
cwdLabel?: string
|
||||
durationLabel?: string
|
||||
model?: string
|
||||
status: string
|
||||
t: Theme
|
||||
// tick is intentionally NOT here — each widget calls useWidgetTicker() internally.
|
||||
// Passing tick via props caused useMemo in AppLayout to rebuild JSX on every second,
|
||||
// which created stale prop snapshots and broke animated text rendering.
|
||||
usage: Usage
|
||||
voiceLabel?: string
|
||||
}
|
||||
|
||||
export interface WidgetSpec {
|
||||
id: string
|
||||
node: ReactNode
|
||||
order?: number
|
||||
region: WidgetRegion
|
||||
// Optional: theme transform applied to `t` before rendering. This lets
|
||||
// individual widgets opt into a different color palette (e.g. Bloomberg)
|
||||
// without touching the main app theme.
|
||||
themeOverride?: (base: Theme) => Theme
|
||||
}
|
||||
|
||||
export interface WidgetRenderState {
|
||||
enabled?: Record<string, boolean>
|
||||
params?: Record<string, Record<string, string>>
|
||||
}
|
||||
|
||||
// ── Theme overrides ───────────────────────────────────────────────────
|
||||
// Bloomberg terminal palette: high-contrast orange/green/red on dark intent.
|
||||
export function bloombergTheme(t: Theme): Theme {
|
||||
return {
|
||||
...t,
|
||||
color: {
|
||||
...t.color,
|
||||
gold: '#FFE000', // bright yellow titles
|
||||
amber: '#FF8C00', // orange for values
|
||||
bronze: '#FF6600', // orange borders
|
||||
cornsilk: '#FFFFFF', // white for primary text
|
||||
dim: '#777777', // gray secondary
|
||||
label: '#FFCC00', // amber labels
|
||||
statusGood: '#00EE00', // classic Bloomberg green
|
||||
statusBad: '#FF2200', // Bloomberg red
|
||||
statusWarn: '#FFAA00'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Data ─────────────────────────────────────────────────────────────
|
||||
interface Asset {
|
||||
label: string
|
||||
series: number[]
|
||||
}
|
||||
|
||||
const BTC: number[] = [
|
||||
83900, 84210, 85140, 84680, 85990, 86540, 87310, 86820, 87940, 88600, 89200, 90100, 90560, 91840, 91210, 92680, 92100,
|
||||
91500, 92900, 93200
|
||||
]
|
||||
|
||||
const ETH: number[] = [
|
||||
2920, 2960, 3015, 2990, 3070, 3050, 3110, 3080, 3160, 3200, 3225, 3260, 3290, 3250, 3310, 3330, 3385, 3360, 3400, 3420
|
||||
]
|
||||
|
||||
const NVDA: number[] = [
|
||||
125, 128, 129, 127, 131, 130, 133, 132, 136, 137, 139, 138, 141, 140, 142, 143, 145, 144, 146, 148
|
||||
]
|
||||
|
||||
const TSLA: number[] = [
|
||||
172, 176, 178, 175, 181, 180, 184, 182, 188, 187, 191, 189, 195, 193, 196, 198, 201, 199, 203, 205
|
||||
]
|
||||
|
||||
const TEMP_DAY: number[] = [65, 66, 67, 69, 71, 73, 74, 75, 74, 73, 72, 71, 70, 69, 68]
|
||||
|
||||
const USD = new Intl.NumberFormat('en-US', { currency: 'USD', maximumFractionDigits: 0, style: 'currency' })
|
||||
const BARS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']
|
||||
const ORBS = ['◐', '◓', '◑', '◒']
|
||||
|
||||
const ASSETS: Asset[] = [
|
||||
{ label: 'BTC', series: BTC },
|
||||
{ label: 'ETH', series: ETH },
|
||||
{ label: 'NVDA', series: NVDA },
|
||||
{ label: 'TSLA', series: TSLA }
|
||||
]
|
||||
|
||||
const CLOCKS = [
|
||||
{ label: 'NYC', tz: 'America/New_York' },
|
||||
{ label: 'LON', tz: 'Europe/London' },
|
||||
{ label: 'TKY', tz: 'Asia/Tokyo' },
|
||||
{ label: 'SYD', tz: 'Australia/Sydney' }
|
||||
]
|
||||
|
||||
const SKY_DESC = ['Overcast', 'Partly cloudy', 'Clear']
|
||||
const SKY_ICON = ['☁', '⛅', '☀']
|
||||
|
||||
// ── Ticker ────────────────────────────────────────────────────────────
|
||||
export function useWidgetTicker(ms = 1000) {
|
||||
const [tick, setTick] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTick(v => v + 1), ms)
|
||||
|
||||
return () => clearInterval(id)
|
||||
}, [ms])
|
||||
|
||||
return tick
|
||||
}
|
||||
|
||||
// ── Pure math helpers ─────────────────────────────────────────────────
|
||||
// Always resamples to exactly `width` points (no early return for small input).
|
||||
// The old `if (values.length <= width) return values` caused the braille grid
|
||||
// to only fill the first N pixel-columns when N < pxWidth.
|
||||
function sample(values: number[], width: number): number[] {
|
||||
if (!values.length || width <= 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (width === 1) {
|
||||
return [values.at(-1) ?? 0]
|
||||
}
|
||||
|
||||
return Array.from({ length: width }, (_, i) => {
|
||||
const pos = (i * (values.length - 1)) / (width - 1)
|
||||
|
||||
return values[Math.round(pos)] ?? values.at(-1) ?? 0
|
||||
})
|
||||
}
|
||||
|
||||
export function sparkline(values: number[], width: number): string {
|
||||
const pts = sample(values, width)
|
||||
|
||||
if (!pts.length) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const lo = Math.min(...pts)
|
||||
const hi = Math.max(...pts)
|
||||
|
||||
if (lo === hi) {
|
||||
return BARS[Math.floor(BARS.length / 2)]!.repeat(pts.length)
|
||||
}
|
||||
|
||||
return pts
|
||||
.map(v => BARS[Math.max(0, Math.min(BARS.length - 1, Math.round(((v - lo) / (hi - lo)) * (BARS.length - 1))))]!)
|
||||
.join('')
|
||||
}
|
||||
|
||||
export function wrapWindow<T>(values: T[], offset: number, width: number): T[] {
|
||||
if (!values.length || width <= 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const start = ((offset % values.length) + values.length) % values.length
|
||||
|
||||
return Array.from({ length: width }, (_, i) => values[(start + i) % values.length]!)
|
||||
}
|
||||
|
||||
export function marquee(text: string, offset: number, width: number): string {
|
||||
if (!text || width <= 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const gap = ' '
|
||||
const source = text + gap + text
|
||||
const span = text.length + gap.length
|
||||
const start = ((offset % span) + span) % span
|
||||
|
||||
return source.slice(start, start + width).padEnd(width, ' ')
|
||||
}
|
||||
|
||||
// ── Braille line chart ────────────────────────────────────────────────
|
||||
function brailleBit(dx: number, dy: number): number {
|
||||
return dx === 0 ? ([0x1, 0x2, 0x4, 0x40][dy] ?? 0) : ([0x8, 0x10, 0x20, 0x80][dy] ?? 0)
|
||||
}
|
||||
|
||||
function drawLine(grid: boolean[][], x0: number, y0: number, x1: number, y1: number) {
|
||||
let x = x0
|
||||
let y = y0
|
||||
const dx = Math.abs(x1 - x0)
|
||||
const sx = x0 < x1 ? 1 : -1
|
||||
const dy = -Math.abs(y1 - y0)
|
||||
const sy = y0 < y1 ? 1 : -1
|
||||
let err = dx + dy
|
||||
|
||||
while (true) {
|
||||
if (grid[y] && x >= 0 && x < grid[y]!.length) {
|
||||
grid[y]![x] = true
|
||||
}
|
||||
|
||||
if (x === x1 && y === y1) {
|
||||
return
|
||||
}
|
||||
|
||||
const e2 = err * 2
|
||||
|
||||
if (e2 >= dy) {
|
||||
err += dy
|
||||
x += sx
|
||||
}
|
||||
|
||||
if (e2 <= dx) {
|
||||
err += dx
|
||||
y += sy
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function plotLineRows(values: number[], width: number, height: number): string[] {
|
||||
if (!values.length || width <= 0 || height <= 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const pxW = Math.max(2, width * 2)
|
||||
const pxH = Math.max(4, height * 4)
|
||||
const pts = sample(values, pxW)
|
||||
const lo = Math.min(...pts)
|
||||
const hi = Math.max(...pts)
|
||||
const grid = Array.from({ length: pxH }, () => new Array<boolean>(pxW).fill(false))
|
||||
|
||||
const yFor = (v: number) => (hi === lo ? Math.floor((pxH - 1) / 2) : Math.round(((hi - v) / (hi - lo)) * (pxH - 1)))
|
||||
|
||||
let prevY = yFor(pts[0] ?? 0)
|
||||
|
||||
if (grid[prevY]) {
|
||||
grid[prevY]![0] = true
|
||||
}
|
||||
|
||||
for (let x = 1; x < pts.length; x++) {
|
||||
const y = yFor(pts[x] ?? pts[x - 1] ?? 0)
|
||||
drawLine(grid, x - 1, prevY, x, y)
|
||||
prevY = y
|
||||
}
|
||||
|
||||
return Array.from({ length: height }, (_, row) => {
|
||||
const top = row * 4
|
||||
|
||||
return Array.from({ length: width }, (_, col) => {
|
||||
const left = col * 2
|
||||
let bits = 0
|
||||
|
||||
for (let dy = 0; dy < 4; dy++) {
|
||||
for (let dx = 0; dx < 2; dx++) {
|
||||
if (grid[top + dy]?.[left + dx]) {
|
||||
bits |= brailleBit(dx, dy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return String.fromCodePoint(0x2800 + bits)
|
||||
}).join('')
|
||||
})
|
||||
}
|
||||
|
||||
// ── Domain helpers ────────────────────────────────────────────────────
|
||||
function pct(now: number, start: number): number {
|
||||
return !start ? 0 : ((now - start) / start) * 100
|
||||
}
|
||||
|
||||
function deltaColor(delta: number, t: Theme): string {
|
||||
return delta > 0 ? t.color.statusGood : delta < 0 ? t.color.statusBad : t.color.dim
|
||||
}
|
||||
|
||||
function money(v: number): string {
|
||||
return USD.format(v)
|
||||
}
|
||||
|
||||
function changeStr(v: number): string {
|
||||
return `${v >= 0 ? '+' : ''}${v.toFixed(1)}%`
|
||||
}
|
||||
|
||||
export function cityTime(tz: string): string {
|
||||
try {
|
||||
return new Date().toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
hour12: false,
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
timeZone: tz
|
||||
})
|
||||
} catch {
|
||||
return '--:--:--'
|
||||
}
|
||||
}
|
||||
|
||||
function fToC(f: number): number {
|
||||
return Math.round(((f - 32) * 5) / 9)
|
||||
}
|
||||
|
||||
// Smooth live points — always starts at series[0], adds an animated live
|
||||
// endpoint oscillating ±0.8% so the chart never has discontinuous jumps.
|
||||
export function livePoints(asset: Asset, tick: number): number[] {
|
||||
const last = asset.series.at(-1) ?? 0
|
||||
const phase = (tick * 0.3) % (Math.PI * 2)
|
||||
const live = Math.round(last * (1 + Math.sin(phase) * 0.008))
|
||||
|
||||
return [...asset.series, live]
|
||||
}
|
||||
|
||||
// ── Primitive components ──────────────────────────────────────────────
|
||||
function LineChart({ color, height, values, width }: { color: any; height: number; values: number[]; width: number }) {
|
||||
const rows = plotLineRows(values, width, height)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{rows.map((row, i) => (
|
||||
<Text color={color} key={i}>
|
||||
{row}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Simple widget frame system:
|
||||
// - bordered widgets: default card chrome
|
||||
// - bleed widgets: full-surface background with internal padding
|
||||
function WidgetFrame({
|
||||
backgroundColor,
|
||||
bordered = true,
|
||||
borderColor,
|
||||
children,
|
||||
paddingX = 1,
|
||||
paddingY = 0,
|
||||
title,
|
||||
titleRight,
|
||||
titleTone,
|
||||
t
|
||||
}: {
|
||||
backgroundColor?: any
|
||||
bordered?: boolean
|
||||
borderColor?: any
|
||||
children: ReactNode
|
||||
paddingX?: number
|
||||
paddingY?: number
|
||||
title: ReactNode
|
||||
titleRight?: ReactNode
|
||||
titleTone?: any
|
||||
t: Theme
|
||||
}) {
|
||||
return (
|
||||
<Box
|
||||
backgroundColor={backgroundColor}
|
||||
borderColor={bordered ? (borderColor ?? t.color.bronze) : undefined}
|
||||
borderStyle={bordered ? 'round' : undefined}
|
||||
flexDirection="column"
|
||||
paddingX={paddingX}
|
||||
paddingY={paddingY}
|
||||
>
|
||||
<Box justifyContent="space-between">
|
||||
<Box flexDirection="row" flexShrink={1}>
|
||||
{typeof title === 'string' || typeof title === 'number' ? (
|
||||
<Text bold color={(titleTone ?? t.color.gold) as any} wrap="truncate-end">
|
||||
{title}
|
||||
</Text>
|
||||
) : (
|
||||
title
|
||||
)}
|
||||
</Box>
|
||||
{titleRight ? (
|
||||
<Box flexDirection="row" flexShrink={0} marginLeft={1}>
|
||||
{typeof titleRight === 'string' || typeof titleRight === 'number' ? (
|
||||
<Text color={t.color.dim as any}>{titleRight}</Text>
|
||||
) : (
|
||||
titleRight
|
||||
)}
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Widgets ───────────────────────────────────────────────────────────
|
||||
|
||||
// Bloomberg-styled hero chart.
|
||||
// Dark navy background for the chart cell so the green/red line pops.
|
||||
// Custom layout (not Card) for full control over the title row structure.
|
||||
// Compact single-line ticker strip for the dock region.
|
||||
function TickerStrip({ cols, t, assetId }: WidgetCtx & { assetId?: string }) {
|
||||
const tick = useWidgetTicker(1200)
|
||||
|
||||
const asset = ASSETS.find(a => a.label.toLowerCase() === assetId?.toLowerCase()) ?? ASSETS[tick % ASSETS.length]!
|
||||
|
||||
const pts = livePoints(asset, tick)
|
||||
const last = pts.at(-1) ?? 0
|
||||
const first = pts[0] ?? 0
|
||||
const change = pct(last, first)
|
||||
const color = deltaColor(change, t)
|
||||
const sparkW = Math.max(8, Math.min(30, cols - 40))
|
||||
|
||||
const others = ASSETS.filter(a => a.label !== asset.label)
|
||||
.map(a => {
|
||||
const c = pct(a.series.at(-1) ?? 0, a.series[0] ?? 0)
|
||||
|
||||
return `${a.label} ${changeStr(c)}`
|
||||
})
|
||||
.join(' ')
|
||||
|
||||
return (
|
||||
<Text color={t.color.dim as any} wrap="truncate-end">
|
||||
<Text bold color={t.color.gold as any}>
|
||||
{asset.label}
|
||||
</Text>
|
||||
<Text color={t.color.cornsilk as any}>{` ${money(last)} `}</Text>
|
||||
<Text bold color={color as any}>
|
||||
{changeStr(change)}
|
||||
</Text>
|
||||
<Text color={t.color.dim as any}>{` ${sparkline(pts, sparkW)} `}</Text>
|
||||
<Text color={t.color.dim as any}>{others}</Text>
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
function WeatherCard({ t }: WidgetCtx) {
|
||||
const tick = useWidgetTicker(2000)
|
||||
const skyIdx = Math.floor(tick / 8) % SKY_DESC.length
|
||||
const desc = SKY_DESC[skyIdx]!
|
||||
const icon = SKY_ICON[skyIdx]!
|
||||
const temp = TEMP_DAY[tick % TEMP_DAY.length]!
|
||||
const wind = 9 + ((tick * 2) % 7)
|
||||
const hum = 64 + (tick % 8)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={t.color.gold as any}>
|
||||
Weather
|
||||
</Text>
|
||||
<Text color={t.color.cornsilk as any} wrap="truncate-end">{`${icon} ${fToC(temp)}C · ${desc}`}</Text>
|
||||
<Text color={t.color.dim as any} wrap="truncate-end">{`Wind ${wind} km/h · Humidity ${hum}%`}</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// 2x2 clock grid in compact rows
|
||||
function WorldClock({ cols, t }: WidgetCtx) {
|
||||
const tick = useWidgetTicker()
|
||||
const orb = ORBS[tick % ORBS.length]!
|
||||
const rows = [CLOCKS.slice(0, 2), CLOCKS.slice(2, 4)] as const
|
||||
const slotW = Math.max(12, Math.floor((Math.max(cols, 24) - 2) / 2))
|
||||
const cell = (label: string, tz: string) => `${label} ${cityTime(tz)}`
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={t.color.gold as any}>{`${orb} World Clock`}</Text>
|
||||
{rows.map((row, i) => (
|
||||
<Box flexDirection="row" key={i}>
|
||||
<Box marginRight={1} width={slotW}>
|
||||
<Text color={t.color.cornsilk as any} wrap="truncate-end">
|
||||
{cell(row[0]!.label, row[0]!.tz)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box width={slotW}>
|
||||
<Text color={t.color.cornsilk as any} wrap="truncate-end">
|
||||
{cell(row[1]!.label, row[1]!.tz)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function HeartBeat({ t }: WidgetCtx) {
|
||||
const tick = useWidgetTicker(700)
|
||||
const bpm = 70 + (tick % 5)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={t.color.gold as any}>
|
||||
Heartbeat
|
||||
</Text>
|
||||
<Text color={t.color.statusBad as any}>{`❤️ ${bpm} bpm`}</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Widget catalog ────────────────────────────────────────────────────
|
||||
export interface WidgetDef {
|
||||
id: string
|
||||
description: string
|
||||
region: WidgetRegion
|
||||
order: number
|
||||
defaultOn: boolean
|
||||
params?: string[]
|
||||
}
|
||||
|
||||
export const WIDGET_CATALOG: WidgetDef[] = [
|
||||
{
|
||||
id: 'ticker',
|
||||
description: 'Live stock ticker strip',
|
||||
region: 'dock',
|
||||
order: 10,
|
||||
defaultOn: true,
|
||||
params: ['asset']
|
||||
},
|
||||
{ id: 'world-clock', description: '2x2 world clock grid', region: 'sidebar', order: 10, defaultOn: true },
|
||||
{ id: 'weather', description: 'Weather conditions', region: 'sidebar', order: 20, defaultOn: true },
|
||||
{ id: 'heartbeat', description: 'Heartbeat monitor', region: 'sidebar', order: 30, defaultOn: true }
|
||||
]
|
||||
|
||||
// ── Registry ──────────────────────────────────────────────────────────
|
||||
export function buildWidgets(ctx: WidgetCtx, state?: WidgetRenderState): WidgetSpec[] {
|
||||
const bt = bloombergTheme(ctx.t)
|
||||
const enabled = state?.enabled
|
||||
const params = state?.params
|
||||
const on = (id: string) => (enabled ? (enabled[id] ?? false) : true)
|
||||
const param = (id: string, key: string) => params?.[id]?.[key]
|
||||
|
||||
const all: WidgetSpec[] = [
|
||||
{
|
||||
id: 'ticker',
|
||||
node: <TickerStrip {...ctx} assetId={param('ticker', 'asset')} t={bt} />,
|
||||
order: 10,
|
||||
region: 'dock'
|
||||
},
|
||||
{ id: 'world-clock', node: <WorldClock {...ctx} />, order: 10, region: 'sidebar' },
|
||||
{ id: 'weather', node: <WeatherCard {...ctx} />, order: 20, region: 'sidebar' },
|
||||
{ id: 'heartbeat', node: <HeartBeat {...ctx} />, order: 30, region: 'sidebar' }
|
||||
]
|
||||
|
||||
return all.filter(w => on(w.id))
|
||||
}
|
||||
|
||||
export function widgetsInRegion(widgets: WidgetSpec[], region: WidgetRegion) {
|
||||
return [...widgets].filter(w => w.region === region).sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||
}
|
||||
|
||||
export function WidgetHost({ region, widgets }: { region: WidgetRegion; widgets: WidgetSpec[] }) {
|
||||
const visible = widgetsInRegion(widgets, region)
|
||||
|
||||
if (!visible.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (region === 'overlay') {
|
||||
return (
|
||||
<>
|
||||
{visible.map(w => (
|
||||
<Fragment key={w.id}>{w.node}</Fragment>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{visible.map((w, i) => (
|
||||
<Box flexDirection="column" key={w.id} marginTop={i === 0 ? 0 : 1}>
|
||||
{w.node}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue