From 121899499237f4cb03903ed62d3efb551e1b0673 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 3 Apr 2026 14:44:50 -0500 Subject: [PATCH] chore: uptick --- hermes_cli/skills_hub.py | 75 ++ tui_gateway/server.py | 922 ++++++++++++++++++------ ui-tui/src/app.tsx | 518 +++++++++++-- ui-tui/src/components/markdown.tsx | 46 ++ ui-tui/src/components/maskedPrompt.tsx | 29 + ui-tui/src/components/sessionPicker.tsx | 94 +++ ui-tui/src/components/thinking.tsx | 6 +- ui-tui/src/constants.ts | 41 +- ui-tui/src/lib/history.ts | 52 ++ ui-tui/src/types.ts | 10 + 10 files changed, 1513 insertions(+), 280 deletions(-) create mode 100644 ui-tui/src/components/maskedPrompt.tsx create mode 100644 ui-tui/src/components/sessionPicker.tsx create mode 100644 ui-tui/src/lib/history.ts diff --git a/hermes_cli/skills_hub.py b/hermes_cli/skills_hub.py index 370b69ab0c..0ecb677fcf 100644 --- a/hermes_cli/skills_hub.py +++ b/hermes_cli/skills_hub.py @@ -496,6 +496,81 @@ def do_inspect(identifier: str, console: Optional[Console] = None) -> None: c.print() +def browse_skills(page: int = 1, page_size: int = 20, source: str = "all") -> list[dict]: + """Paginated hub browse for programmatic callers (e.g. TUI gateway).""" + from tools.skills_hub import GitHubAuth, create_source_router + + page_size = max(1, min(page_size, 100)) + _TRUST_RANK = {"builtin": 3, "trusted": 2, "community": 1} + _PER_SOURCE_LIMIT = {"official": 100, "skills-sh": 100, "well-known": 25, "github": 100, "clawhub": 50, + "claude-marketplace": 50, "lobehub": 50} + auth = GitHubAuth() + sources = create_source_router(auth) + all_results: list = [] + for src in sources: + sid = src.source_id() + if source != "all" and sid != source and sid != "official": + continue + try: + limit = _PER_SOURCE_LIMIT.get(sid, 50) + all_results.extend(src.search("", limit=limit)) + except Exception: + continue + if not all_results: + return [] + seen: dict = {} + for r in all_results: + rank = _TRUST_RANK.get(r.trust_level, 0) + if r.name not in seen or rank > _TRUST_RANK.get(seen[r.name].trust_level, 0): + seen[r.name] = r + deduped = list(seen.values()) + deduped.sort(key=lambda r: (-_TRUST_RANK.get(r.trust_level, 0), r.source != "official", r.name.lower())) + total = len(deduped) + total_pages = max(1, (total + page_size - 1) // page_size) + page = max(1, min(page, total_pages)) + start = (page - 1) * page_size + page_items = deduped[start : min(start + page_size, total)] + return [{"name": r.name, "description": r.description} for r in page_items] + + +def inspect_skill(identifier: str) -> Optional[dict]: + """Skill metadata (+ SKILL.md preview) for programmatic callers.""" + from tools.skills_hub import GitHubAuth, create_source_router + + class _Q: + def print(self, *a, **k): + pass + + c = _Q() + auth = GitHubAuth() + sources = create_source_router(auth) + ident = identifier + if "/" not in ident: + ident = _resolve_short_name(ident, sources, c) + if not ident: + return None + meta, bundle, _ = _resolve_source_meta_and_bundle(ident, sources) + if not meta: + return None + out: dict = { + "name": meta.name, + "description": meta.description, + "source": meta.source, + "identifier": meta.identifier, + "tags": list(meta.tags) if meta.tags else [], + } + if bundle and "SKILL.md" in bundle.files: + content = bundle.files["SKILL.md"] + if isinstance(content, bytes): + content = content.decode("utf-8", errors="replace") + lines = content.split("\n") + preview = "\n".join(lines[:50]) + if len(lines) > 50: + preview += f"\n\n... ({len(lines) - 50} more lines)" + out["skill_md_preview"] = preview + return out + + def do_list(source_filter: str = "all", console: Optional[Console] = None) -> None: """List installed skills, distinguishing hub, builtin, and local skills.""" from tools.skills_hub import HubLockFile, ensure_hub_dirs diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 616d63ef94..c8262e639f 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -13,27 +13,36 @@ _hermes_home = get_hermes_home() load_hermes_dotenv(hermes_home=_hermes_home, project_env=Path(__file__).parent.parent / ".env") _sessions: dict[str, dict] = {} -_methods: dict[str, callable] = {} -_clarify_pending: dict[str, threading.Event] = {} -_clarify_answers: dict[str, str] = {} +_methods: dict[str, callable] = {} +_pending: dict[str, threading.Event] = {} +_answers: dict[str, str] = {} +_db = None -# ── Wire ───────────────────────────────────────────────────────────── +# ── Plumbing ────────────────────────────────────────────────────────── -def _emit(event_type: str, sid: str, payload: dict | None = None): - params = {"type": event_type, "session_id": sid} +def _get_db(): + global _db + if _db is None: + from hermes_state import SessionDB + _db = SessionDB() + return _db + + +def _emit(event: str, sid: str, payload: dict | None = None): + params = {"type": event, "session_id": sid} if payload: params["payload"] = payload sys.stdout.write(json.dumps({"jsonrpc": "2.0", "method": "event", "params": params}) + "\n") sys.stdout.flush() -def _ok(req_id, result: dict) -> dict: - return {"jsonrpc": "2.0", "id": req_id, "result": result} +def _ok(rid, result: dict) -> dict: + return {"jsonrpc": "2.0", "id": rid, "result": result} -def _err(req_id, code: int, msg: str) -> dict: - return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": msg}} +def _err(rid, code: int, msg: str) -> dict: + return {"jsonrpc": "2.0", "id": rid, "error": {"code": code, "message": msg}} def method(name: str): @@ -50,18 +59,56 @@ def handle_request(req: dict) -> dict | None: return fn(req.get("id"), req.get("params", {})) -# ── Helpers ────────────────────────────────────────────────────────── +def _sess(params, rid): + s = _sessions.get(params.get("session_id", "")) + return (s, None) if s else (None, _err(rid, 4001, "session not found")) + + +# ── Config I/O ──────────────────────────────────────────────────────── + +def _load_cfg() -> dict: + try: + import yaml + p = _hermes_home / "config.yaml" + if p.exists(): + with open(p) as f: + return yaml.safe_load(f) or {} + except Exception: + pass + return {} + + +def _save_cfg(cfg: dict): + import yaml + with open(_hermes_home / "config.yaml", "w") as f: + yaml.safe_dump(cfg, f) + + +# ── Blocking prompt factory ────────────────────────────────────────── + +def _block(event: str, sid: str, payload: dict, timeout: int = 300) -> str: + rid = uuid.uuid4().hex[:8] + ev = threading.Event() + _pending[rid] = ev + payload["request_id"] = rid + _emit(event, sid, payload) + ev.wait(timeout=timeout) + _pending.pop(rid, None) + return _answers.pop(rid, "") + + +def _clear_pending(): + for rid, ev in list(_pending.items()): + _answers[rid] = "" + ev.set() + + +# ── Agent factory ──────────────────────────────────────────────────── def resolve_skin() -> dict: try: - import yaml from hermes_cli.skin_engine import init_skin_from_config, get_active_skin - cfg_path = _hermes_home / "config.yaml" - cfg = {} - if cfg_path.exists(): - with open(cfg_path) as f: - cfg = yaml.safe_load(f) or {} - init_skin_from_config(cfg) + init_skin_from_config(_load_cfg()) skin = get_active_skin() return {"name": skin.name, "colors": skin.colors, "branding": skin.branding} except Exception: @@ -72,32 +119,25 @@ def _resolve_model() -> str: env = os.environ.get("HERMES_MODEL", "") if env: return env - try: - import yaml - cfg_path = _hermes_home / "config.yaml" - if cfg_path.exists(): - with open(cfg_path) as f: - m = (yaml.safe_load(f) or {}).get("model", "") - if isinstance(m, dict): - return m.get("default", "") - if isinstance(m, str): - return m - except Exception: - pass + m = _load_cfg().get("model", "") + if isinstance(m, dict): + return m.get("default", "") + if isinstance(m, str) and m: + return m return "anthropic/claude-sonnet-4" def _get_usage(agent) -> dict: - ga = lambda k, fb=None: getattr(agent, k, 0) or (getattr(agent, fb, 0) if fb else 0) + g = lambda k, fb=None: getattr(agent, k, 0) or (getattr(agent, fb, 0) if fb else 0) return { - "input": ga("session_input_tokens", "session_prompt_tokens"), - "output": ga("session_output_tokens", "session_completion_tokens"), - "total": ga("session_total_tokens"), - "calls": ga("session_api_calls"), + "input": g("session_input_tokens", "session_prompt_tokens"), + "output": g("session_output_tokens", "session_completion_tokens"), + "total": g("session_total_tokens"), + "calls": g("session_api_calls"), } -def _collect_session_info(agent) -> dict: +def _session_info(agent) -> dict: info: dict = {"model": getattr(agent, "model", ""), "tools": {}, "skills": {}} try: from model_tools import get_toolset_for_tool @@ -114,242 +154,690 @@ def _collect_session_info(agent) -> dict: return info -def _make_clarify_cb(sid: str): - def cb(question: str, choices: list | None) -> str: - rid = uuid.uuid4().hex[:8] - ev = threading.Event() - _clarify_pending[rid] = ev - _emit("clarify.request", sid, {"request_id": rid, "question": question, "choices": choices}) - ev.wait(timeout=300) - _clarify_pending.pop(rid, None) - return _clarify_answers.pop(rid, "") - return cb +def _agent_cbs(sid: str) -> dict: + return dict( + tool_start_callback=lambda tc_id, name, args: _emit("tool.start", sid, {"tool_id": tc_id, "name": name}), + tool_complete_callback=lambda tc_id, name, args, result: _emit("tool.complete", sid, {"tool_id": tc_id, "name": name}), + tool_progress_callback=lambda name, preview, args: _emit("tool.progress", sid, {"name": name, "preview": preview}), + tool_gen_callback=lambda name: _emit("tool.generating", sid, {"name": name}), + thinking_callback=lambda text: _emit("thinking.delta", sid, {"text": text}), + reasoning_callback=lambda text: _emit("reasoning.delta", sid, {"text": text}), + status_callback=lambda text: _emit("status.update", sid, {"text": text}), + clarify_callback=lambda q, c: _block("clarify.request", sid, {"question": q, "choices": c}), + ) -def _register_approval_notify(sid: str, session_key: str): +def _wire_callbacks(sid: str): + from tools.terminal_tool import set_sudo_password_callback + from tools.skills_tool import set_secret_capture_callback + + set_sudo_password_callback(lambda: _block("sudo.request", sid, {}, timeout=120)) + + def secret_cb(env_var, prompt, metadata=None): + pl = {"prompt": prompt, "env_var": env_var} + if metadata: + pl["metadata"] = metadata + val = _block("secret.request", sid, pl) + if not val: + return {"success": True, "stored_as": env_var, "validated": False, "skipped": True, "message": "skipped"} + from hermes_cli.config import save_env_value_secure + return {**save_env_value_secure(env_var, val), "skipped": False, "message": "ok"} + + set_secret_capture_callback(secret_cb) + + +def _make_agent(sid: str, key: str, session_id: str | None = None): + from run_agent import AIAgent + return AIAgent( + model=_resolve_model(), quiet_mode=True, platform="tui", + session_id=session_id or key, session_db=_get_db(), **_agent_cbs(sid), + ) + + +def _init_session(sid: str, key: str, agent, history: list): + _sessions[sid] = {"agent": agent, "session_key": key, "history": history} try: - from tools.approval import register_gateway_notify - register_gateway_notify(session_key, lambda data: _emit("approval.request", sid, data)) + from tools.approval import register_gateway_notify, load_permanent_allowlist + register_gateway_notify(key, lambda data: _emit("approval.request", sid, data)) + load_permanent_allowlist() except Exception: pass + _wire_callbacks(sid) + _emit("session.info", sid, _session_info(agent)) -# ── Methods ────────────────────────────────────────────────────────── +def _with_checkpoints(session, fn): + return fn(session["agent"]._checkpoint_mgr, os.getenv("TERMINAL_CWD", os.getcwd())) + + +# ── Methods: session ───────────────────────────────────────────────── @method("session.create") -def _(req_id, params: dict) -> dict: +def _(rid, params: dict) -> dict: sid = uuid.uuid4().hex[:8] - session_key = f"tui-{sid}" - - os.environ["HERMES_SESSION_KEY"] = session_key + key = f"tui-{sid}" + os.environ["HERMES_SESSION_KEY"] = key os.environ["HERMES_INTERACTIVE"] = "1" - try: - from run_agent import AIAgent - agent = AIAgent( - model=_resolve_model(), - quiet_mode=True, - platform="tui", - tool_start_callback=lambda tc_id, name, args: _emit("tool.start", sid, {"tool_id": tc_id, "name": name}), - tool_complete_callback=lambda tc_id, name, args, result: _emit("tool.complete", sid, {"tool_id": tc_id, "name": name}), - tool_progress_callback=lambda name, preview, args: _emit("tool.progress", sid, {"name": name, "preview": preview}), - tool_gen_callback=lambda name: _emit("tool.generating", sid, {"name": name}), - thinking_callback=lambda text: _emit("thinking.delta", sid, {"text": text}), - reasoning_callback=lambda text: _emit("reasoning.delta", sid, {"text": text}), - status_callback=lambda text: _emit("status.update", sid, {"text": text}), - clarify_callback=_make_clarify_cb(sid), - ) - _sessions[sid] = {"agent": agent, "session_key": session_key, "history": []} + agent = _make_agent(sid, key) + _get_db().create_session(key, source="tui", model=_resolve_model()) + _init_session(sid, key, agent, []) except Exception as e: - return _err(req_id, 5000, f"agent init failed: {e}") - - _register_approval_notify(sid, session_key) - - from tools.approval import load_permanent_allowlist - load_permanent_allowlist() - - _emit("session.info", sid, _collect_session_info(agent)) - return _ok(req_id, {"session_id": sid}) + return _err(rid, 5000, f"agent init failed: {e}") + return _ok(rid, {"session_id": sid}) -@method("prompt.submit") -def _(req_id, params: dict) -> dict: - sid, text = params.get("session_id", ""), params.get("text", "") - session = _sessions.get(sid) - if not session: - return _err(req_id, 4001, "session not found") - - agent = session["agent"] - history = session["history"] - _emit("message.start", sid) - - def run(): - try: - result = agent.run_conversation( - text, - conversation_history=list(history), - stream_callback=lambda delta: _emit("message.delta", sid, {"text": delta}), - ) - - if isinstance(result, dict): - returned_msgs = result.get("messages") - if isinstance(returned_msgs, list): - session["history"] = returned_msgs - final = result.get("final_response", "") - status = "interrupted" if result.get("interrupted") else "error" if result.get("error") else "complete" - _emit("message.complete", sid, { - "text": final or "", - "usage": _get_usage(agent), - "status": status, - }) - else: - _emit("message.complete", sid, {"text": str(result), "usage": _get_usage(agent), "status": "complete"}) - - except Exception as e: - _emit("error", sid, {"message": str(e)}) - - threading.Thread(target=run, daemon=True).start() - return _ok(req_id, {"status": "streaming"}) - - -@method("clarify.respond") -def _(req_id, params: dict) -> dict: - rid = params.get("request_id", "") - ev = _clarify_pending.get(rid) - if not ev: - return _err(req_id, 4003, "no pending clarify request") - _clarify_answers[rid] = params.get("answer", "") - ev.set() - return _ok(req_id, {"status": "ok"}) - - -@method("approval.respond") -def _(req_id, params: dict) -> dict: - sid = params.get("session_id", "") - choice = params.get("choice", "deny") - - session = _sessions.get(sid) - if not session: - return _err(req_id, 4001, "session not found") - +@method("session.list") +def _(rid, params: dict) -> dict: try: - from tools.approval import resolve_gateway_approval - n = resolve_gateway_approval(session["session_key"], choice, resolve_all=params.get("all", False)) - return _ok(req_id, {"resolved": n}) + rows = _get_db().list_sessions_rich(source="tui", limit=params.get("limit", 20)) + return _ok(rid, {"sessions": [ + {"id": s["id"], "title": s.get("title") or "", "preview": s.get("preview") or "", + "started_at": s.get("started_at") or 0, "message_count": s.get("message_count") or 0} + for s in rows + ]}) except Exception as e: - return _err(req_id, 5004, str(e)) + return _err(rid, 5006, str(e)) + + +@method("session.resume") +def _(rid, params: dict) -> dict: + target = params.get("session_id", "") + if not target: + return _err(rid, 4006, "session_id required") + db = _get_db() + found = db.get_session(target) + if not found: + found = db.get_session_by_title(target) + if found: + target = found["id"] + 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 = [{"role": m["role"], "content": m["content"]} + for m in db.get_messages(target) + if m.get("role") in ("user", "assistant", "tool", "system")] + agent = _make_agent(sid, target, session_id=target) + _init_session(sid, target, agent, history) + except Exception as e: + return _err(rid, 5000, f"resume failed: {e}") + return _ok(rid, {"session_id": sid, "resumed": target, "message_count": len(history)}) + + +@method("session.title") +def _(rid, params: dict) -> dict: + session, err = _sess(params, rid) + if err: + return err + title, key = params.get("title", ""), session["session_key"] + if not title: + return _ok(rid, {"title": _get_db().get_session_title(key) or "", "session_key": key}) + try: + _get_db().set_session_title(key, title) + return _ok(rid, {"title": title}) + except Exception as e: + return _err(rid, 5007, str(e)) @method("session.usage") -def _(req_id, params: dict) -> dict: - session = _sessions.get(params.get("session_id", "")) - if not session: - return _err(req_id, 4001, "session not found") - return _ok(req_id, _get_usage(session["agent"])) +def _(rid, params: dict) -> dict: + session, err = _sess(params, rid) + return err or _ok(rid, _get_usage(session["agent"])) @method("session.history") -def _(req_id, params: dict) -> dict: - session = _sessions.get(params.get("session_id", "")) - if not session: - return _err(req_id, 4001, "session not found") - return _ok(req_id, {"count": len(session.get("history", []))}) +def _(rid, params: dict) -> dict: + session, err = _sess(params, rid) + return err or _ok(rid, {"count": len(session.get("history", []))}) @method("session.undo") -def _(req_id, params: dict) -> dict: - session = _sessions.get(params.get("session_id", "")) - if not session: - return _err(req_id, 4001, "session not found") - history = session.get("history", []) - removed = 0 +def _(rid, params: dict) -> dict: + session, err = _sess(params, rid) + if err: + return err + history, removed = session.get("history", []), 0 while history and history[-1].get("role") in ("assistant", "tool"): history.pop(); removed += 1 if history and history[-1].get("role") == "user": history.pop(); removed += 1 - return _ok(req_id, {"removed": removed}) + return _ok(rid, {"removed": removed}) @method("session.compress") -def _(req_id, params: dict) -> dict: - session = _sessions.get(params.get("session_id", "")) - if not session: - return _err(req_id, 4001, "session not found") +def _(rid, params: dict) -> dict: + session, err = _sess(params, rid) + if err: + return err agent = session["agent"] try: if hasattr(agent, "compress_context"): agent.compress_context() - return _ok(req_id, {"status": "compressed", "usage": _get_usage(agent)}) + return _ok(rid, {"status": "compressed", "usage": _get_usage(agent)}) except Exception as e: - return _err(req_id, 5005, str(e)) + return _err(rid, 5005, str(e)) -@method("config.set") -def _(req_id, params: dict) -> dict: - key, value = params.get("key", ""), params.get("value", "") - - if key == "model": - os.environ["HERMES_MODEL"] = value - return _ok(req_id, {"key": key, "value": value}) - - if key == "skin": - try: - import yaml - cfg_path = _hermes_home / "config.yaml" - cfg = {} - if cfg_path.exists(): - with open(cfg_path) as f: - cfg = yaml.safe_load(f) or {} - cfg["skin"] = value - with open(cfg_path, "w") as f: - yaml.safe_dump(cfg, f) - return _ok(req_id, {"key": key, "value": value}) - except Exception as e: - return _err(req_id, 5001, str(e)) - - return _err(req_id, 4002, f"unknown config key: {key}") +@method("session.save") +def _(rid, params: dict) -> dict: + session, err = _sess(params, rid) + if err: + return err + import time as _time + filename = f"hermes_conversation_{_time.strftime('%Y%m%d_%H%M%S')}.json" + try: + with open(filename, "w") as f: + json.dump({"model": getattr(session["agent"], "model", ""), "messages": session.get("history", [])}, + f, indent=2, ensure_ascii=False) + return _ok(rid, {"file": filename}) + except Exception as e: + return _err(rid, 5011, str(e)) @method("session.interrupt") -def _(req_id, params: dict) -> dict: - session = _sessions.get(params.get("session_id", "")) - if not session: - return _err(req_id, 4001, "session not found") - +def _(rid, params: dict) -> dict: + session, err = _sess(params, rid) + if err: + return err if hasattr(session["agent"], "interrupt"): session["agent"].interrupt() - - for rid, ev in list(_clarify_pending.items()): - _clarify_answers[rid] = "" - ev.set() - + _clear_pending() try: from tools.approval import resolve_gateway_approval resolve_gateway_approval(session["session_key"], "deny", resolve_all=True) except Exception: pass - - return _ok(req_id, {"status": "interrupted"}) + return _ok(rid, {"status": "interrupted"}) -@method("shell.exec") -def _(req_id, params: dict) -> dict: - cmd = params.get("command", "") - if not cmd: - return _err(req_id, 4004, "empty command") +# ── Methods: prompt ────────────────────────────────────────────────── + +@method("prompt.submit") +def _(rid, params: dict) -> dict: + sid, text = params.get("session_id", ""), params.get("text", "") + session = _sessions.get(sid) + if not session: + return _err(rid, 4001, "session not found") + agent, history = session["agent"], session["history"] + _emit("message.start", sid) + + def run(): + try: + result = agent.run_conversation( + text, conversation_history=list(history), + stream_callback=lambda delta: _emit("message.delta", sid, {"text": delta}), + ) + if isinstance(result, dict): + if isinstance(result.get("messages"), list): + session["history"] = result["messages"] + status = "interrupted" if result.get("interrupted") else "error" if result.get("error") else "complete" + _emit("message.complete", sid, {"text": result.get("final_response", ""), "usage": _get_usage(agent), "status": status}) + else: + _emit("message.complete", sid, {"text": str(result), "usage": _get_usage(agent), "status": "complete"}) + except Exception as e: + _emit("error", sid, {"message": str(e)}) + + threading.Thread(target=run, daemon=True).start() + return _ok(rid, {"status": "streaming"}) + + +@method("prompt.background") +def _(rid, params: dict) -> dict: + text, parent = params.get("text", ""), params.get("session_id", "") + if not text: + return _err(rid, 4012, "text required") + task_id = f"bg_{uuid.uuid4().hex[:6]}" + + def run(): + 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) + _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}"}) + + threading.Thread(target=run, daemon=True).start() + return _ok(rid, {"task_id": task_id}) + + +@method("prompt.btw") +def _(rid, params: dict) -> dict: + session, err = _sess(params, rid) + if err: + return err + text, sid = params.get("text", ""), params.get("session_id", "") + if not text: + return _err(rid, 4012, "text required") + snapshot = list(session.get("history", [])) + + def run(): + try: + from run_agent import AIAgent + result = AIAgent(model=_resolve_model(), quiet_mode=True, platform="tui", + max_iterations=8, enabled_toolsets=[]).run_conversation(text, conversation_history=snapshot) + _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}"}) + + threading.Thread(target=run, daemon=True).start() + return _ok(rid, {"status": "running"}) + + +# ── Methods: respond ───────────────────────────────────────────────── + +def _respond(rid, params, key): + r = params.get("request_id", "") + ev = _pending.get(r) + if not ev: + return _err(rid, 4009, f"no pending {key} request") + _answers[r] = params.get(key, "") + ev.set() + return _ok(rid, {"status": "ok"}) + + +@method("clarify.respond") +def _(rid, params: dict) -> dict: + return _respond(rid, params, "answer") + +@method("sudo.respond") +def _(rid, params: dict) -> dict: + return _respond(rid, params, "password") + +@method("secret.respond") +def _(rid, params: dict) -> dict: + return _respond(rid, params, "value") + +@method("approval.respond") +def _(rid, params: dict) -> dict: + session, err = _sess(params, rid) + if err: + return err + try: + from tools.approval import resolve_gateway_approval + return _ok(rid, {"resolved": resolve_gateway_approval( + session["session_key"], params.get("choice", "deny"), resolve_all=params.get("all", False))}) + except Exception as e: + return _err(rid, 5004, str(e)) + + +# ── Methods: config ────────────────────────────────────────────────── + +@method("config.set") +def _(rid, params: dict) -> dict: + key, value = params.get("key", ""), params.get("value", "") + + if key == "model": + os.environ["HERMES_MODEL"] = value + return _ok(rid, {"key": key, "value": value}) + + if key == "verbose": + cycle = ["off", "new", "all", "verbose"] + if value and value != "cycle": + os.environ["HERMES_VERBOSE"] = value + return _ok(rid, {"key": key, "value": value}) + cur = os.environ.get("HERMES_VERBOSE", "all") + try: + idx = cycle.index(cur) + except ValueError: + idx = 2 + nv = cycle[(idx + 1) % len(cycle)] + os.environ["HERMES_VERBOSE"] = nv + return _ok(rid, {"key": key, "value": nv}) + + if key == "yolo": + nv = "0" if os.environ.get("HERMES_YOLO", "0") == "1" else "1" + os.environ["HERMES_YOLO"] = nv + return _ok(rid, {"key": key, "value": nv}) + + if key == "reasoning": + if value in ("show", "on"): + os.environ["HERMES_SHOW_REASONING"] = "1" + return _ok(rid, {"key": key, "value": "show"}) + if value in ("hide", "off"): + os.environ.pop("HERMES_SHOW_REASONING", None) + return _ok(rid, {"key": key, "value": "hide"}) + os.environ["HERMES_REASONING"] = value + return _ok(rid, {"key": key, "value": value}) + + if key in ("prompt", "personality", "skin"): + try: + cfg = _load_cfg() + if key == "prompt": + if value == "clear": + cfg.pop("custom_prompt", None) + nv = "" + else: + cfg["custom_prompt"] = value + nv = value + elif key == "personality": + cfg.setdefault("display", {})["personality"] = value if value not in ("none", "default", "neutral") else "" + nv = value + else: + cfg.setdefault("display", {})[key] = value + nv = value + _save_cfg(cfg) + return _ok(rid, {"key": key, "value": nv}) + except Exception as e: + return _err(rid, 5001, str(e)) + + return _err(rid, 4002, f"unknown config key: {key}") + + +@method("config.get") +def _(rid, params: dict) -> dict: + key = params.get("key", "") + if key == "provider": + try: + from hermes_cli.models import list_available_providers, normalize_provider + model = _resolve_model() + parts = model.split("/", 1) + return _ok(rid, {"model": model, "provider": normalize_provider(parts[0]) if len(parts) > 1 else "unknown", + "providers": list_available_providers()}) + except Exception as e: + return _err(rid, 5013, str(e)) + if key == "profile": + from hermes_constants import display_hermes_home + return _ok(rid, {"home": str(_hermes_home), "display": display_hermes_home()}) + if key == "full": + return _ok(rid, {"config": _load_cfg()}) + if key == "prompt": + return _ok(rid, {"prompt": _load_cfg().get("custom_prompt", "")}) + return _err(rid, 4002, f"unknown config key: {key}") + + +# ── Methods: tools & system ────────────────────────────────────────── + +@method("process.stop") +def _(rid, params: dict) -> dict: + try: + from tools.process_registry import ProcessRegistry + return _ok(rid, {"killed": ProcessRegistry().kill_all()}) + except Exception as e: + return _err(rid, 5010, str(e)) + + +@method("reload.mcp") +def _(rid, params: dict) -> dict: + session = _sessions.get(params.get("session_id", "")) + try: + from tools.mcp_tool import shutdown_mcp_servers, discover_mcp_tools + shutdown_mcp_servers() + discover_mcp_tools() + if session: + agent = session["agent"] + if hasattr(agent, "refresh_tools"): + agent.refresh_tools() + _emit("session.info", params.get("session_id", ""), _session_info(agent)) + return _ok(rid, {"status": "reloaded"}) + except Exception as e: + return _err(rid, 5015, str(e)) + + +@method("command.resolve") +def _(rid, params: dict) -> dict: + try: + from hermes_cli.commands import resolve_command + r = resolve_command(params.get("name", "")) + if r: + return _ok(rid, {"canonical": r.name, "description": r.description, "category": r.category}) + return _err(rid, 4011, f"unknown command: {params.get('name')}") + except Exception as e: + return _err(rid, 5012, str(e)) + + +@method("command.dispatch") +def _(rid, params: dict) -> dict: + name, arg = params.get("name", "").lstrip("/"), params.get("arg", "") + session = _sessions.get(params.get("session_id", "")) + + qcmds = _load_cfg().get("quick_commands", {}) + if name in qcmds: + qc = qcmds[name] + if qc.get("type") == "exec": + r = subprocess.run(qc.get("command", ""), shell=True, capture_output=True, text=True, timeout=30) + return _ok(rid, {"type": "exec", "output": (r.stdout or r.stderr)[:4000]}) + if qc.get("type") == "alias": + return _ok(rid, {"type": "alias", "target": qc.get("target", "")}) try: - from tools.approval import detect_dangerous_command - is_dangerous, _, description = detect_dangerous_command(cmd) - if is_dangerous: - return _err(req_id, 4005, f"blocked: {description}. Use the agent for dangerous commands (it has approval flow).") - except ImportError: + from hermes_cli.plugins import get_plugin_command_handler + handler = get_plugin_command_handler(name) + if handler: + return _ok(rid, {"type": "plugin", "output": str(handler(arg) or "")}) + except Exception: pass try: - r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=30, cwd=os.getcwd()) - return _ok(req_id, {"stdout": r.stdout[-4000:], "stderr": r.stderr[-2000:], "code": r.returncode}) - except subprocess.TimeoutExpired: - return _err(req_id, 5002, "command timed out (30s)") + from agent.skill_commands import scan_skill_commands, build_skill_invocation_message + cmds = scan_skill_commands() + key = f"/{name}" + if key in cmds: + msg = build_skill_invocation_message(key, arg, task_id=session.get("session_key", "") if session else "") + if msg: + return _ok(rid, {"type": "skill", "message": msg, "name": cmds[key].get("name", name)}) + except Exception: + pass + + return _err(rid, 4018, f"not a quick/plugin/skill command: {name}") + + +# ── Methods: voice ─────────────────────────────────────────────────── + +@method("voice.toggle") +def _(rid, params: dict) -> dict: + action = params.get("action", "status") + if action == "status": + return _ok(rid, {"enabled": os.environ.get("HERMES_VOICE", "0") == "1"}) + if action in ("on", "off"): + os.environ["HERMES_VOICE"] = "1" if action == "on" else "0" + return _ok(rid, {"enabled": action == "on"}) + return _err(rid, 4013, f"unknown voice action: {action}") + + +@method("voice.record") +def _(rid, params: dict) -> dict: + action = params.get("action", "start") + try: + if action == "start": + from hermes_cli.voice import start_recording + start_recording() + return _ok(rid, {"status": "recording"}) + if action == "stop": + from hermes_cli.voice import stop_and_transcribe + return _ok(rid, {"text": stop_and_transcribe() or ""}) + return _err(rid, 4019, f"unknown voice action: {action}") + except ImportError: + return _err(rid, 5025, "voice module not available — install audio dependencies") except Exception as e: - return _err(req_id, 5003, str(e)) + return _err(rid, 5025, str(e)) + + +@method("voice.tts") +def _(rid, params: dict) -> dict: + text = params.get("text", "") + if not text: + return _err(rid, 4020, "text required") + try: + from hermes_cli.voice import speak_text + threading.Thread(target=speak_text, args=(text,), daemon=True).start() + return _ok(rid, {"status": "speaking"}) + except ImportError: + return _err(rid, 5026, "voice module not available") + except Exception as e: + return _err(rid, 5026, str(e)) + + +# ── Methods: insights ──────────────────────────────────────────────── + +@method("insights.get") +def _(rid, params: dict) -> dict: + days = params.get("days", 30) + try: + import time + cutoff = time.time() - days * 86400 + rows = [s for s in _get_db().list_sessions_rich(limit=500) if (s.get("started_at") or 0) >= cutoff] + return _ok(rid, {"days": days, "sessions": len(rows), "messages": sum(s.get("message_count", 0) for s in rows)}) + except Exception as e: + return _err(rid, 5017, str(e)) + + +# ── Methods: rollback ──────────────────────────────────────────────── + +@method("rollback.list") +def _(rid, params: dict) -> dict: + session, err = _sess(params, rid) + if err: + return err + try: + def go(mgr, cwd): + if not mgr.enabled: + return _ok(rid, {"enabled": False, "checkpoints": []}) + return _ok(rid, {"enabled": True, "checkpoints": [ + {"hash": c.get("hash", ""), "timestamp": c.get("timestamp", ""), "message": c.get("message", "")} + for c in mgr.list_checkpoints(cwd)]}) + return _with_checkpoints(session, go) + except Exception as e: + return _err(rid, 5020, str(e)) + + +@method("rollback.restore") +def _(rid, params: dict) -> dict: + session, err = _sess(params, rid) + if err: + return err + target = params.get("hash", "") + if not target: + return _err(rid, 4014, "hash required") + try: + return _ok(rid, _with_checkpoints(session, lambda mgr, cwd: mgr.restore(cwd, target))) + except Exception as e: + return _err(rid, 5021, str(e)) + + +@method("rollback.diff") +def _(rid, params: dict) -> dict: + session, err = _sess(params, rid) + if err: + return err + target = params.get("hash", "") + if not target: + return _err(rid, 4014, "hash required") + try: + r = _with_checkpoints(session, lambda mgr, cwd: mgr.diff(cwd, target)) + return _ok(rid, {"stat": r.get("stat", ""), "diff": r.get("diff", "")[:4000]}) + except Exception as e: + return _err(rid, 5022, str(e)) + + +# ── Methods: browser / plugins / cron / skills ─────────────────────── + +@method("browser.manage") +def _(rid, params: dict) -> dict: + action = params.get("action", "status") + if action == "status": + url = os.environ.get("BROWSER_CDP_URL", "") + return _ok(rid, {"connected": bool(url), "url": url}) + if action == "connect": + url = params.get("url", "http://localhost:9222") + os.environ["BROWSER_CDP_URL"] = url + try: + from tools.browser_tool import cleanup_all_browsers + cleanup_all_browsers() + except Exception: + pass + return _ok(rid, {"connected": True, "url": url}) + if action == "disconnect": + os.environ.pop("BROWSER_CDP_URL", None) + try: + from tools.browser_tool import cleanup_all_browsers + cleanup_all_browsers() + except Exception: + pass + return _ok(rid, {"connected": False}) + return _err(rid, 4015, f"unknown action: {action}") + + +@method("plugins.list") +def _(rid, params: dict) -> dict: + try: + from hermes_cli.plugins import get_plugin_manager + return _ok(rid, {"plugins": [ + {"name": n, "version": getattr(i, "version", "?"), "enabled": getattr(i, "enabled", True)} + for n, i in get_plugin_manager()._plugins.items()]}) + except Exception: + return _ok(rid, {"plugins": []}) + + +@method("cron.manage") +def _(rid, params: dict) -> dict: + action, jid = params.get("action", "list"), params.get("name", "") + try: + from tools.cronjob_tools import cronjob + if action == "list": + return _ok(rid, json.loads(cronjob(action="list"))) + if action == "add": + return _ok(rid, json.loads(cronjob(action="create", name=jid, + schedule=params.get("schedule", ""), prompt=params.get("prompt", "")))) + if action in ("remove", "pause", "resume"): + return _ok(rid, json.loads(cronjob(action=action, job_id=jid))) + return _err(rid, 4016, f"unknown cron action: {action}") + except Exception as e: + return _err(rid, 5023, str(e)) + + +@method("skills.manage") +def _(rid, params: dict) -> dict: + action, query = params.get("action", "list"), params.get("query", "") + try: + if action == "list": + from hermes_cli.banner import get_available_skills + return _ok(rid, {"skills": get_available_skills()}) + if action == "search": + from hermes_cli.skills_hub import unified_search, GitHubAuth, create_source_router + raw = unified_search(query, create_source_router(GitHubAuth()), source_filter="all", limit=20) or [] + return _ok(rid, {"results": [{"name": r.name, "description": r.description} for r in raw]}) + if action == "install": + from hermes_cli.skills_hub import do_install + class _Q: + def print(self, *a, **k): pass + do_install(query, skip_confirm=True, console=_Q()) + return _ok(rid, {"installed": True, "name": query}) + if action == "browse": + from hermes_cli.skills_hub import browse_skills + return _ok(rid, {"results": [{"name": r.get("name", ""), "description": r.get("description", "")} + for r in (browse_skills(page=int(query) if query.isdigit() else 1) or [])]}) + if action == "inspect": + from hermes_cli.skills_hub import inspect_skill + return _ok(rid, {"info": inspect_skill(query) or {}}) + return _err(rid, 4017, f"unknown skills action: {action}") + except Exception as e: + return _err(rid, 5024, str(e)) + + +# ── Methods: shell ─────────────────────────────────────────────────── + +@method("shell.exec") +def _(rid, params: dict) -> dict: + cmd = params.get("command", "") + if not cmd: + return _err(rid, 4004, "empty command") + try: + from tools.approval import detect_dangerous_command + is_dangerous, _, desc = detect_dangerous_command(cmd) + if is_dangerous: + return _err(rid, 4005, f"blocked: {desc}. Use the agent for dangerous commands.") + except ImportError: + pass + try: + r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=30, cwd=os.getcwd()) + return _ok(rid, {"stdout": r.stdout[-4000:], "stderr": r.stderr[-2000:], "code": r.returncode}) + except subprocess.TimeoutExpired: + return _err(rid, 5002, "command timed out (30s)") + except Exception as e: + return _err(rid, 5003, str(e)) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 9623d10add..b6ecf42e71 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -8,14 +8,16 @@ import { CommandPalette } from './components/commandPalette.js' import { MessageLine } from './components/messageLine.js' import { ApprovalPrompt, ClarifyPrompt } from './components/prompts.js' import { QueuedMessages } from './components/queuedMessages.js' +import { MaskedPrompt } from './components/maskedPrompt.js' +import { SessionPicker } from './components/sessionPicker.js' import { Thinking } from './components/thinking.js' import { COMMANDS, HOTKEYS, INTERPOLATION_RE, MAX_CTX, PLACEHOLDERS, TOOL_VERBS, ZERO } from './constants.js' -import type { GatewayClient } from './gatewayClient.js' -import { type GatewayEvent } from './gatewayClient.js' +import { type GatewayClient, type GatewayEvent } from './gatewayClient.js' +import * as inputHistory from './lib/history.js' import { upsert } from './lib/messages.js' import { estimateRows, flat, fmtK, hasInterpolation, pick, userDisplay } from './lib/text.js' import { DEFAULT_THEME, fromSkin, type Theme } from './theme.js' -import type { ActiveTool, ApprovalReq, ClarifyReq, Msg, SessionInfo, Usage } from './types.js' +import type { ActiveTool, ApprovalReq, ClarifyReq, Msg, SecretReq, SessionInfo, SudoReq, Usage } from './types.js' const PLACEHOLDER = pick(PLACEHOLDERS) @@ -39,7 +41,12 @@ export function App({ gw }: { gw: GatewayClient }) { const [usage, setUsage] = useState(ZERO) const [clarify, setClarify] = useState(null) const [approval, setApproval] = useState(null) + const [sudo, setSudo] = useState(null) + const [secret, setSecret] = useState(null) + const [picker, setPicker] = useState(false) const [reasoning, setReasoning] = useState('') + const [thinkingText, setThinkingText] = useState('') + const [statusBar, setStatusBar] = useState(true) const [lastUserMsg, setLastUserMsg] = useState('') const [queueEditIdx, setQueueEditIdx] = useState(null) const [historyIdx, setHistoryIdx] = useState(null) @@ -49,13 +56,13 @@ export function App({ gw }: { gw: GatewayClient }) { const buf = useRef('') const stickyRef = useRef(true) const queueRef = useRef([]) - const historyRef = useRef([]) + const historyRef = useRef(inputHistory.load()) const historyDraftRef = useRef('') const queueEditRef = useRef(null) const lastEmptyAt = useRef(0) const empty = !messages.length - const blocked = !!(clarify || approval) + const blocked = !!(clarify || approval || sudo || secret || picker) const syncQueue = () => setQueuedDisplay([...queueRef.current]) @@ -84,9 +91,9 @@ export function App({ gw }: { gw: GatewayClient }) { const pushHistory = (text: string) => { const trimmed = text.trim() - if (trimmed && historyRef.current.at(-1) !== trimmed) { historyRef.current.push(trimmed) + inputHistory.append(trimmed) } } @@ -128,13 +135,31 @@ export function App({ gw }: { gw: GatewayClient }) { const sys = useCallback((text: string) => setMessages(prev => [...prev, { role: 'system' as const, text }]), []) + const rpc = (method: string, params: Record = {}) => + gw.request(method, params).catch((e: Error) => { + sys(`error: ${e.message}`) + }) + + const newSession = (msg?: string) => + rpc('session.create').then((r: any) => { + if (!r) return + setSid(r.session_id) + setMessages([]) + setUsage(ZERO) + setStatus('ready') + if (msg) sys(msg) + }) + const idle = () => { setThinking(false) setTools([]) setBusy(false) setClarify(null) setApproval(null) + setSudo(null) + setSecret(null) setReasoning('') + setThinkingText('') } const die = () => { @@ -229,10 +254,22 @@ export function App({ gw }: { gw: GatewayClient }) { useInput((ch, key) => { if (blocked) { - if (key.ctrl && ch === 'c' && approval) { - gw.request('approval.respond', { choice: 'deny', session_id: sid }).catch(() => {}) - setApproval(null) - sys('denied') + if (key.ctrl && ch === 'c') { + if (approval) { + gw.request('approval.respond', { choice: 'deny', session_id: sid }).catch(() => {}) + setApproval(null) + sys('denied') + } else if (sudo) { + gw.request('sudo.respond', { request_id: sudo.requestId, password: '' }).catch(() => {}) + setSudo(null) + sys('sudo cancelled') + } else if (secret) { + gw.request('secret.respond', { request_id: secret.requestId, value: '' }).catch(() => {}) + setSecret(null) + sys('secret entry cancelled') + } else if (picker) { + setPicker(false) + } } return @@ -336,12 +373,7 @@ export function App({ gw }: { gw: GatewayClient }) { } setStatus('forging session…') - gw.request('session.create') - .then((r: any) => { - setSid(r.session_id) - setStatus('ready') - }) - .catch((e: Error) => setStatus(`error: ${e.message}`)) + newSession() break @@ -351,12 +383,14 @@ export function App({ gw }: { gw: GatewayClient }) { break case 'thinking.delta': + if (p?.text) setThinkingText(prev => prev + p.text) break case 'message.start': setThinking(true) setBusy(true) setReasoning('') + setThinkingText('') setStatus('thinking…') break @@ -414,7 +448,24 @@ export function App({ gw }: { gw: GatewayClient }) { case 'approval.request': setApproval({ command: p.command, description: p.description }) setStatus('approval needed') + break + case 'sudo.request': + setSudo({ requestId: p.request_id }) + setStatus('sudo password needed') + break + + case 'secret.request': + setSecret({ requestId: p.request_id, prompt: p.prompt, envVar: p.env_var }) + setStatus('secret input needed') + break + + case 'background.complete': + sys(`[bg ${p.task_id}] ${p.text}`) + break + + case 'btw.complete': + sys(`[btw] ${p.text}`) break case 'message.delta': @@ -474,7 +525,7 @@ export function App({ gw }: { gw: GatewayClient }) { } }, // eslint-disable-next-line react-hooks/exhaustive-deps - [gw, sys] + [gw, sys, newSession] ) useEffect(() => { @@ -509,8 +560,8 @@ export function App({ gw }: { gw: GatewayClient }) { return true case 'clear': - setMessages([]) - + setStatus('forging session…') + newSession() return true case 'quit': // falls through @@ -522,16 +573,7 @@ export function App({ gw }: { gw: GatewayClient }) { case 'new': setStatus('forging session…') - gw.request('session.create') - .then((r: any) => { - setSid(r.session_id) - setMessages([]) - setUsage(ZERO) - setStatus('ready') - sys('new session started') - }) - .catch((e: Error) => setStatus(`error: ${e.message}`)) - + newSession('new session started') return true case 'undo': @@ -539,7 +581,7 @@ export function App({ gw }: { gw: GatewayClient }) { return true } - gw.request('session.undo', { session_id: sid }) + rpc('session.undo', { session_id: sid }) .then((r: any) => { if (r.removed > 0) { setMessages(prev => { @@ -560,28 +602,23 @@ export function App({ gw }: { gw: GatewayClient }) { sys('nothing to undo') } }) - .catch((e: Error) => sys(`error: ${e.message}`)) return true case 'retry': if (!lastUserMsg) { sys('nothing to retry') - return true } - + if (sid) { + gw.request('session.undo', { session_id: sid }).catch(() => {}) + } setMessages(prev => { const q = [...prev] - - while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') { - q.pop() - } - + while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') q.pop() return q }) send(lastUserMsg) - return true case 'compact': @@ -595,7 +632,7 @@ export function App({ gw }: { gw: GatewayClient }) { return true } - gw.request('session.compress', { session_id: sid }) + rpc('session.compress', { session_id: sid }) .then((r: any) => { sys('context compressed') @@ -603,7 +640,6 @@ export function App({ gw }: { gw: GatewayClient }) { setUsage(r.usage) } }) - .catch((e: Error) => sys(`error: ${e.message}`)) return true @@ -656,19 +692,321 @@ export function App({ gw }: { gw: GatewayClient }) { return true - case 'skills': - if (!info?.skills || !Object.keys(info.skills).length) { - sys('no skills loaded') + case 'resume': + setPicker(true) + return true + case 'history': + if (!sid) { setPicker(true); return true } + rpc('session.history', { session_id: sid }) + .then((r: any) => sys(`session ${sid}: ${r.count} messages in context`)) + return true + + case 'title': + if (!sid) return true + if (!arg) { + rpc('session.title', { session_id: sid }) + .then((r: any) => sys(`title: ${r.title || '(none)'} session: ${r.session_key}`)) return true } + rpc('session.title', { session_id: sid, title: arg }) + .then(() => sys(`title → ${arg}`)) + return true + case 'tools': + if (!info?.tools || !Object.keys(info.tools).length) { + sys('no tools loaded') + return true + } sys( - Object.entries(info.skills) - .map(([k, vs]) => `${k}: ${vs.join(', ')}`) + Object.entries(info.tools) + .map(([k, vs]) => `${k} (${vs.length}): ${vs.join(', ')}`) .join('\n') ) + return true + case 'skills': + if (!arg || arg === 'list') { + if (!info?.skills || !Object.keys(info.skills).length) { + sys('no skills loaded') + return true + } + sys(Object.entries(info.skills).map(([k, vs]) => `${k}: ${vs.join(', ')}`).join('\n')) + return true + } + if (arg.startsWith('search ')) { + rpc('skills.manage', { action: 'search', query: arg.slice(7).trim() }) + .then((r: any) => { + if (!r.results?.length) { sys('no results'); return } + sys(r.results.map((s: any) => ` ${s.name}: ${s.description}`).join('\n')) + }) + return true + } + if (arg.startsWith('install ')) { + rpc('skills.manage', { action: 'install', query: arg.slice(8).trim() }) + .then((r: any) => sys(r.installed ? `installed ${r.name}` : 'install failed')) + return true + } + if (arg === 'browse' || arg.startsWith('browse ')) { + rpc('skills.manage', { action: 'browse', query: arg.slice(6).trim() }) + .then((r: any) => { + if (!r.results?.length) { sys('no skills available'); return } + sys(r.results.map((s: any) => ` ${s.name}: ${s.description}`).join('\n')) + }) + return true + } + if (arg.startsWith('inspect ')) { + rpc('skills.manage', { action: 'inspect', query: arg.slice(8).trim() }) + .then((r: any) => sys(JSON.stringify(r.info, null, 2))) + return true + } + sys('usage: /skills [list|search |install |browse|inspect ]') + return true + + case 'verbose': + rpc('config.set', { key: 'verbose', value: arg || 'cycle' }) + .then((r: any) => sys(`verbose → ${r.value}`)) + return true + + case 'yolo': + rpc('config.set', { key: 'yolo', value: '' }) + .then((r: any) => sys(`yolo → ${r.value === '1' ? 'on' : 'off'}`)) + return true + + case 'reasoning': + if (!arg) { + sys('usage: /reasoning ') + return true + } + rpc('config.set', { key: 'reasoning', value: arg }) + .then((r: any) => sys(`reasoning → ${r.value}`)) + return true + + case 'stop': + rpc('process.stop') + .then((r: any) => sys(`killed ${r.killed} process(es)`)) + return true + + case 'profile': + gw.request('config.get', { key: 'profile' }) + .then((r: any) => sys(`profile: ${r.display}`)) + .catch(() => sys(`profile: ${process.env.HERMES_HOME ?? '~/.hermes'}`)) + return true + + case 'save': + if (!sid) return true + rpc('session.save', { session_id: sid }) + .then((r: any) => sys(`saved to ${r.file}`)) + return true + + case 'provider': + rpc('config.get', { key: 'provider' }) + .then((r: any) => { + const lines = [`model: ${r.model} provider: ${r.provider}`] + if (r.providers?.length) lines.push(`available: ${r.providers.join(', ')}`) + sys(lines.join('\n')) + }) + return true + + case 'prompt': + if (!arg) { + rpc('config.get', { key: 'prompt' }) + .then((r: any) => sys(`custom prompt: ${r.prompt || '(none set)'}`)) + return true + } + rpc('config.set', { key: 'prompt', value: arg }) + .then((r: any) => sys(r.value ? `prompt set (${r.value.length} chars)` : 'prompt cleared')) + return true + + case 'personality': + if (!arg) { + sys('usage: /personality (concise, creative, analytical, friendly, none)') + return true + } + rpc('config.set', { key: 'personality', value: arg }) + .then((r: any) => sys(`personality → ${r.value || 'default'}`)) + return true + + case 'plan': + send(arg ? `/plan ${arg}` : 'Create a detailed plan for the current task.') + return true + + case 'background': + case 'bg': + if (!arg) { + sys('usage: /background ') + return true + } + rpc('prompt.background', { session_id: sid, text: arg }) + .then((r: any) => sys(`background task ${r.task_id} started`)) + return true + + case 'btw': + if (!arg) { + sys('usage: /btw ') + return true + } + rpc('prompt.btw', { session_id: sid, text: arg }) + .then(() => sys('btw running…')) + return true + + case 'queue': + if (!arg) { + sys(`${queueRef.current.length} queued message(s)`) + return true + } + enqueue(arg) + sys(`queued: "${arg.slice(0, 50)}${arg.length > 50 ? '…' : ''}"`) + return true + + case 'rollback': + if (!sid) return true + if (!arg) { + rpc('rollback.list', { session_id: sid }) + .then((r: any) => { + if (!r.enabled) { sys('checkpoints not enabled — use hermes --checkpoints'); return } + if (!r.checkpoints?.length) { sys('no checkpoints'); return } + sys(r.checkpoints.map((c: any, i: number) => + ` ${i + 1}. ${c.message || c.hash.slice(0, 8)} (${c.timestamp})` + ).join('\n')) + }) + return true + } + if (arg.startsWith('diff ')) { + const ref = arg.slice(5).trim() + rpc('rollback.list', { session_id: sid }).then((r: any) => { + const hash = /^\d+$/.test(ref) ? r.checkpoints?.[parseInt(ref) - 1]?.hash : ref + if (!hash) { sys(`checkpoint ${ref} not found`); return } + rpc('rollback.diff', { session_id: sid, hash }) + .then((d: any) => sys(d.stat || d.diff || 'no changes')) + }) + return true + } + { + const parts = arg.trim().split(/\s+/) + const ref = parts[0]! + const file = parts[1] + rpc('rollback.list', { session_id: sid }).then((r: any) => { + const hash = /^\d+$/.test(ref) ? r.checkpoints?.[parseInt(ref) - 1]?.hash : ref + if (!hash) { sys(`checkpoint ${ref} not found`); return } + rpc('rollback.restore', { session_id: sid, hash, ...(file ? { file } : {}) }) + .then((d: any) => sys(d.success ? `restored${file ? ` ${file}` : ''}` : `failed: ${d.error || 'unknown'}`)) + }) + } + return true + + case 'insights': + rpc('insights.get', { days: arg ? parseInt(arg) : 30 }) + .then((r: any) => sys(`last ${r.days}d: ${r.sessions} sessions, ${r.messages} messages`)) + return true + + case 'toolsets': + if (!info?.tools) { + sys('no toolsets loaded') + return true + } + sys(Object.entries(info.tools).map(([k, vs]) => `${k}: ${vs.length} tools`).join('\n')) + return true + + case 'paste': + sys('clipboard paste: use your terminal\'s paste shortcut (images not yet supported in TUI)') + return true + + case 'reload-mcp': + case 'reload_mcp': + rpc('reload.mcp', { session_id: sid }) + .then(() => sys('MCP servers reloaded')) + return true + + case 'browser': + if (!arg || arg === 'status') { + rpc('browser.manage', { action: 'status' }) + .then((r: any) => sys(r.connected ? `browser: connected (${r.url})` : 'browser: not connected')) + } else if (arg === 'connect' || arg.startsWith('connect ')) { + const url = arg.split(/\s+/)[1] + rpc('browser.manage', { action: 'connect', ...(url ? { url } : {}) }) + .then((r: any) => sys(`browser connected: ${r.url}`)) + } else if (arg === 'disconnect') { + rpc('browser.manage', { action: 'disconnect' }) + .then(() => sys('browser disconnected')) + } else { + sys('usage: /browser [connect|disconnect|status]') + } + return true + + case 'platforms': + case 'gateway': + sys('gateway status is not available in TUI mode') + return true + + case 'statusbar': + case 'sb': + setStatusBar(v => !v) + sys(`status bar ${statusBar ? 'off' : 'on'}`) + return true + + case 'voice': + if (!arg || arg === 'status') { + rpc('voice.toggle', { action: 'status' }) + .then((r: any) => sys(`voice: ${r.enabled ? 'on' : 'off'}`)) + } else if (arg === 'on' || arg === 'off') { + rpc('voice.toggle', { action: arg }) + .then((r: any) => sys(`voice → ${r.enabled ? 'on' : 'off'}`)) + } else if (arg === 'record') { + rpc('voice.record', { action: 'start' }) + .then(() => sys('recording… (use /voice stop to transcribe)')) + } else if (arg === 'stop') { + rpc('voice.record', { action: 'stop' }) + .then((r: any) => { + if (r.text) { send(r.text) } else { sys('no speech detected') } + }) + } else if (arg === 'tts') { + const last = messages.filter(m => m.role === 'assistant').at(-1) + if (last) { + rpc('voice.tts', { text: last.text }) + .then(() => sys('speaking…')) + } else { + sys('no response to speak') + } + } else { + sys('usage: /voice [on|off|status|record|stop|tts]') + } + return true + + case 'plugins': + rpc('plugins.list') + .then((r: any) => { + if (!r.plugins?.length) { sys('no plugins installed'); return } + sys(r.plugins.map((p: any) => ` ${p.name} v${p.version} ${p.enabled ? '✓' : '✗'}`).join('\n')) + }) + return true + + case 'cron': + if (!arg || arg === 'list') { + rpc('cron.manage', { action: 'list' }) + .then((r: any) => { + const jobs = r.jobs || r.schedules || [] + if (!jobs.length) { sys('no cron jobs'); return } + sys(jobs.map((j: any) => ` ${j.name}: ${j.schedule} ${j.paused ? '(paused)' : ''}`).join('\n')) + }) + } else { + const parts = arg.split(/\s+/) + const sub = parts[0]! + if (sub === 'add' || sub === 'create') { + const name = parts[1] || '' + const schedule = parts[2] || '' + const prompt = parts.slice(3).join(' ') + rpc('cron.manage', { action: 'add', name, schedule, prompt }) + .then((r: any) => sys(r.message || r.status || 'created')) + } else { + rpc('cron.manage', { action: sub, name: parts[1] || '' }) + .then((r: any) => sys(r.message || r.status || JSON.stringify(r))) + } + } + return true + + case 'update': + sys('update not available in TUI mode — run: pip install -U hermes-agent') return true case 'model': @@ -678,9 +1016,8 @@ export function App({ gw }: { gw: GatewayClient }) { return true } - gw.request('config.set', { key: 'model', value: arg }) + rpc('config.set', { key: 'model', value: arg }) .then(() => sys(`model → ${arg}`)) - .catch((e: Error) => sys(`error: ${e.message}`)) return true @@ -691,18 +1028,42 @@ export function App({ gw }: { gw: GatewayClient }) { return true } - gw.request('config.set', { key: 'skin', value: arg }) + rpc('config.set', { key: 'skin', value: arg }) .then(() => sys(`skin → ${arg} (restart to apply)`)) - .catch((e: Error) => sys(`error: ${e.message}`)) return true default: - return false + gw.request('command.dispatch', { name: name ?? '', arg, session_id: sid }) + .then((r: any) => { + if (r.type === 'exec') { + sys(r.output || '(no output)') + } else if (r.type === 'alias') { + slash(`/${r.target}${arg ? ' ' + arg : ''}`) + } else if (r.type === 'plugin') { + sys(r.output || '(no output)') + } else if (r.type === 'skill') { + sys(`⚡ loading skill: ${r.name}`) + send(r.message) + } + }) + .catch(() => { + gw.request('command.resolve', { name: name ?? '' }) + .then((r: any) => { + if (r.canonical && r.canonical !== name) { + sys(`/${name} → /${r.canonical}`) + slash(`/${r.canonical}${arg ? ' ' + arg : ''}`) + } else { + sys(`unknown command: /${name}`) + } + }) + .catch(() => sys(`unknown command: /${name}`)) + }) + return true } }, // eslint-disable-next-line react-hooks/exhaustive-deps - [compact, gw, info, lastUserMsg, messages, sid, status, sys, usage] + [compact, gw, info, lastUserMsg, messages, newSession, rpc, send, sid, status, sys, usage, statusBar] ) const submit = useCallback( @@ -878,7 +1239,7 @@ export function App({ gw }: { gw: GatewayClient }) { )} - {thinking && } + {thinking && } {clarify && ( @@ -907,6 +1268,57 @@ export function App({ gw }: { gw: GatewayClient }) { /> )} + {sudo && ( + { + gw.request('sudo.respond', { request_id: sudo.requestId, password }).catch(() => {}) + setSudo(null) + setStatus('running…') + }} + t={theme} + /> + )} + + {secret && ( + { + gw.request('secret.respond', { request_id: secret.requestId, value }).catch(() => {}) + setSecret(null) + setStatus('running…') + }} + sub={`for ${secret.envVar}`} + t={theme} + /> + )} + + {picker && ( + setPicker(false)} + onSelect={id => { + setPicker(false) + setStatus('resuming…') + gw.request('session.resume', { session_id: id }) + .then((r: any) => { + setSid(r.session_id) + setMessages([]) + setUsage(ZERO) + sys(`resumed session (${r.message_count} messages)`) + setStatus('ready') + }) + .catch((e: Error) => { + sys(`error: ${e.message}`) + setStatus('ready') + }) + }} + t={theme} + /> + )} + {!blocked && input.startsWith('/') && } diff --git a/ui-tui/src/components/markdown.tsx b/ui-tui/src/components/markdown.tsx index c0d71403bf..99f24a59b1 100644 --- a/ui-tui/src/components/markdown.tsx +++ b/ui-tui/src/components/markdown.tsx @@ -144,6 +144,52 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st continue } + if (line.match(/^>\s?/)) { + const quoteLines: string[] = [] + while (i < lines.length && lines[i]!.match(/^>\s?/)) { + quoteLines.push(lines[i]!.replace(/^>\s?/, '')) + i++ + } + nodes.push( + + {quoteLines.map((ql, qi) => ( + + {' │ '} + + ))} + + ) + continue + } + + if (line.includes('|') && line.trim().startsWith('|')) { + const tableRows: string[][] = [] + while (i < lines.length && lines[i]!.trim().startsWith('|')) { + const row = lines[i]!.trim() + if (!/^[|\s:-]+$/.test(row)) { + tableRows.push( + row.split('|').filter(Boolean).map(c => c.trim()) + ) + } + i++ + } + if (tableRows.length) { + const widths = tableRows[0]!.map((_, ci) => + Math.max(...tableRows.map(r => (r[ci] ?? '').length)) + ) + nodes.push( + + {tableRows.map((row, ri) => ( + + {row.map((cell, ci) => cell.padEnd(widths[ci] ?? 0)).join(' ')} + + ))} + + ) + } + continue + } + nodes.push() i++ } diff --git a/ui-tui/src/components/maskedPrompt.tsx b/ui-tui/src/components/maskedPrompt.tsx new file mode 100644 index 0000000000..96fe21e1c8 --- /dev/null +++ b/ui-tui/src/components/maskedPrompt.tsx @@ -0,0 +1,29 @@ +import { Box, Text } from 'ink' +import TextInput from 'ink-text-input' +import { useState } from 'react' + +import type { Theme } from '../theme.js' + +export function MaskedPrompt({ + icon, label, onSubmit, sub, t +}: { + icon: string + label: string + onSubmit: (v: string) => void + sub?: string + t: Theme +}) { + const [value, setValue] = useState('') + + return ( + + {icon} {label} + {sub && {sub}} + + + {'> '} + + + + ) +} diff --git a/ui-tui/src/components/sessionPicker.tsx b/ui-tui/src/components/sessionPicker.tsx new file mode 100644 index 0000000000..baa8ad25b2 --- /dev/null +++ b/ui-tui/src/components/sessionPicker.tsx @@ -0,0 +1,94 @@ +import { Box, Text, useInput } from 'ink' +import { useEffect, useState } from 'react' + +import type { GatewayClient } from '../gatewayClient.js' +import type { Theme } from '../theme.js' + +interface SessionItem { + id: string + title: string + preview: string + started_at: number + message_count: number +} + +function age(ts: number): string { + const d = (Date.now() / 1000 - ts) / 86400 + if (d < 1) return 'today' + if (d < 2) return 'yesterday' + return `${Math.floor(d)}d ago` +} + +const VISIBLE = 15 + +export function SessionPicker({ + gw, + onCancel, + onSelect, + t +}: { + gw: GatewayClient + onCancel: () => void + onSelect: (id: string) => void + t: Theme +}) { + const [items, setItems] = useState([]) + const [sel, setSel] = useState(0) + const [loading, setLoading] = useState(true) + + useEffect(() => { + gw.request('session.list', { limit: 20 }) + .then((r: any) => { + setItems(r.sessions ?? []) + setLoading(false) + }) + .catch(() => setLoading(false)) + }, [gw]) + + useInput((ch, key) => { + if (key.escape) return onCancel() + if (key.upArrow && sel > 0) setSel(s => s - 1) + if (key.downArrow && sel < items.length - 1) setSel(s => s + 1) + if (key.return && items[sel]) onSelect(items[sel]!.id) + + const n = parseInt(ch) + if (n >= 1 && n <= Math.min(9, items.length)) onSelect(items[n - 1]!.id) + }) + + if (loading) return loading sessions… + + if (!items.length) { + return ( + + no previous sessions + Esc to cancel + + ) + } + + const off = Math.max(0, Math.min(sel - Math.floor(VISIBLE / 2), items.length - VISIBLE)) + const visible = items.slice(off, off + VISIBLE) + + return ( + + Resume Session + {off > 0 && ↑ {off} more} + {visible.map((s, vi) => { + const i = off + vi + return ( + + {sel === i ? '▸ ' : ' '} + + {i + 1}. {s.title || s.preview || s.id.slice(0, 8)} + + + {' '}({s.message_count} msgs, {age(s.started_at)}) + + + ) + })} + {off + VISIBLE < items.length && ↓ {items.length - off - VISIBLE} more} + ↑/↓ select · Enter resume · 1-9 quick · Esc cancel + + ) +} diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index f5d5cd3da2..f5e7351fad 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -6,7 +6,7 @@ import { pick } from '../lib/text.js' import type { Theme } from '../theme.js' import type { ActiveTool } from '../types.js' -export function Thinking({ reasoning, t, tools }: { reasoning: string; t: Theme; tools: ActiveTool[] }) { +export function Thinking({ reasoning, t, thinking, tools }: { reasoning: string; t: Theme; thinking?: string; tools: ActiveTool[] }) { const [frame, setFrame] = useState(0) const [verb] = useState(() => pick(VERBS)) const [face] = useState(() => pick(FACES)) @@ -30,10 +30,10 @@ export function Thinking({ reasoning, t, tools }: { reasoning: string; t: Theme; {SPINNER[frame]} {face} {verb}… )} - {reasoning && ( + {(reasoning || thinking) && ( {' 💭 '} - {reasoning.slice(-120).replace(/\n/g, ' ')} + {(reasoning || thinking || '').slice(-120).replace(/\n/g, ' ')} )} diff --git a/ui-tui/src/constants.ts b/ui-tui/src/constants.ts index a8d2a99c12..5b921807b0 100644 --- a/ui-tui/src/constants.ts +++ b/ui-tui/src/constants.ts @@ -3,20 +3,45 @@ import type { Role, Usage } from './types.js' export const COMMANDS: [string, string][] = [ ['/help', 'commands & hotkeys'], - ['/model', 'switch model'], - ['/skin', 'change theme'], - ['/clear', 'reset chat'], ['/new', 'new session'], + ['/resume', 'resume a previous session'], + ['/title', 'set session title'], + ['/history', 'show session list'], + ['/clear', 'reset session + chat'], ['/undo', 'drop last exchange'], ['/retry', 'resend last message'], + ['/save', 'save conversation to file'], ['/compact', 'toggle compact [focus]'], - ['/cost', 'token usage stats'], - ['/copy', 'copy last response'], - ['/context', 'context window info'], ['/compress', 'compress context'], + ['/model', 'switch model'], + ['/skin', 'change theme'], + ['/provider', 'show model/provider info'], + ['/prompt', 'set custom system prompt'], + ['/personality', 'set personality preset'], + ['/verbose', 'cycle tool verbosity'], + ['/yolo', 'toggle auto-approve mode'], + ['/reasoning', 'set reasoning level'], + ['/tools', 'list active tools'], + ['/toolsets', 'list toolsets'], ['/skills', 'list skills'], + ['/stop', 'kill background processes'], + ['/background', 'run prompt in background'], + ['/btw', 'side question (no tools)'], + ['/plan', 'invoke plan skill'], + ['/queue', 'queue prompt for next turn'], + ['/profile', 'show active profile'], + ['/cost', 'token usage stats'], + ['/context', 'context window info'], + ['/insights', 'usage analytics'], + ['/copy', 'copy last response'], + ['/paste', 'clipboard info'], ['/config', 'show config'], ['/status', 'session info'], + ['/statusbar', 'toggle status bar'], + ['/voice', 'voice mode toggle'], + ['/reload-mcp', 'reload MCP servers'], + ['/rollback', 'checkpoint info'], + ['/browser', 'browser tools info'], ['/quit', 'exit hermes'] ] @@ -47,7 +72,9 @@ export const HOTKEYS: [string, string][] = [ ['Esc', 'clear input'], ['\\+Enter', 'multi-line continuation'], ['!cmd', 'run shell command'], - ['{!cmd}', 'interpolate shell output inline'] + ['{!cmd}', 'interpolate shell output inline'], + ['/voice record', 'start PTT recording'], + ['/voice stop', 'stop + transcribe'] ] export const INTERPOLATION_RE = /\{!(.+?)\}/g diff --git a/ui-tui/src/lib/history.ts b/ui-tui/src/lib/history.ts new file mode 100644 index 0000000000..b77ba44439 --- /dev/null +++ b/ui-tui/src/lib/history.ts @@ -0,0 +1,52 @@ +import { existsSync, mkdirSync, readFileSync, appendFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' + +const MAX = 1000 +const dir = join(process.env.HERMES_HOME ?? join(homedir(), '.hermes')) +const file = join(dir, 'tui_history') + +let cache: string[] | null = null + +function encode(s: string): string { + return s.replace(/\\/g, '\\\\').replace(/\n/g, '\\n') +} + +function decode(s: string): string { + return s.replace(/\\n/g, '\n').replace(/\\\\/g, '\\') +} + +export function load(): string[] { + if (cache) return cache + try { + if (existsSync(file)) { + cache = readFileSync(file, 'utf8') + .split('\n') + .filter(Boolean) + .map(decode) + .slice(-MAX) + } else { + cache = [] + } + } catch { + cache = [] + } + return cache +} + +export function append(line: string): void { + const trimmed = line.trim() + if (!trimmed) return + const items = load() + if (items.at(-1) === trimmed) return + items.push(trimmed) + if (items.length > MAX) items.splice(0, items.length - MAX) + try { + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) + appendFileSync(file, encode(trimmed) + '\n') + } catch { /* ignore */ } +} + +export function all(): string[] { + return load() +} diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 4b4084eb4d..253c46069c 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -33,3 +33,13 @@ export interface Usage { output: number total: number } + +export interface SudoReq { + requestId: string +} + +export interface SecretReq { + envVar: string + prompt: string + requestId: string +}