From aeb53131f3e09a7006d890cc4f78d5e729dbcc55 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 13 Apr 2026 18:29:24 -0500 Subject: [PATCH] fix(ui-tui): harden TUI error handling, model validation, command UX parity, and gateway lifecycle --- cli.py | 6 + hermes_cli/commands.py | 6 +- hermes_cli/models.py | 12 +- tests/hermes_cli/test_model_validation.py | 32 +- tests/test_tui_gateway_server.py | 108 +++- tui_gateway/server.py | 386 +++++++++++--- ui-tui/.gitignore | 1 + ui-tui/src/app.tsx | 619 ++++++++++++++++------ ui-tui/src/components/modelPicker.tsx | 241 +++++++++ ui-tui/src/components/textInput.tsx | 82 ++- ui-tui/src/constants.ts | 2 +- ui-tui/src/gatewayClient.ts | 99 +++- ui-tui/src/hooks/useCompletion.ts | 15 +- ui-tui/src/lib/text.ts | 2 +- ui-tui/src/types.ts | 1 + 15 files changed, 1303 insertions(+), 309 deletions(-) create mode 100644 ui-tui/src/components/modelPicker.tsx diff --git a/cli.py b/cli.py index efeccf5cf..4312a6b54 100644 --- a/cli.py +++ b/cli.py @@ -1194,6 +1194,10 @@ def _resolve_attachment_path(raw_path: str) -> Path | None: return None expanded = os.path.expandvars(os.path.expanduser(token)) + if os.name != "nt": + normalized = expanded.replace("\\", "/") + if len(normalized) >= 3 and normalized[1] == ":" and normalized[2] == "/" and normalized[0].isalpha(): + expanded = f"/mnt/{normalized[0].lower()}/{normalized[3:]}" path = Path(expanded) if not path.is_absolute(): base_dir = Path(os.getenv("TERMINAL_CWD", os.getcwd())) @@ -1276,10 +1280,12 @@ def _detect_file_drop(user_input: str) -> "dict | None": or stripped.startswith("~") or stripped.startswith("./") or stripped.startswith("../") + or (len(stripped) >= 3 and stripped[1] == ":" and stripped[2] in ("\\", "/") and stripped[0].isalpha()) or stripped.startswith('"/') or stripped.startswith('"~') or stripped.startswith("'/") or stripped.startswith("'~") + or (len(stripped) >= 4 and stripped[0] in ("'", '"') and stripped[2] == ":" and stripped[3] in ("\\", "/") and stripped[1].isalpha()) ) if not starts_like_path: return None diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index ebd13f54b..3d1f37035 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -73,7 +73,7 @@ COMMAND_REGISTRY: list[CommandDef] = [ args_hint="[focus topic]"), CommandDef("rollback", "List or restore filesystem checkpoints", "Session", args_hint="[number]"), - CommandDef("stop", "Kill all running background processes", "Session"), + CommandDef("stop", "Kill all running registered subprocesses", "Session"), CommandDef("approve", "Approve a pending dangerous command", "Session", gateway_only=True, args_hint="[session|always]"), CommandDef("deny", "Deny a pending dangerous command", "Session", @@ -96,7 +96,7 @@ COMMAND_REGISTRY: list[CommandDef] = [ # Configuration CommandDef("config", "Show current configuration", "Configuration", cli_only=True), - CommandDef("model", "Switch model for this session", "Configuration", args_hint="[model] [--global]"), + CommandDef("model", "Switch model for this session", "Configuration", args_hint="[model] [--provider name] [--global]"), CommandDef("provider", "Show available providers and current provider", "Configuration"), @@ -152,7 +152,7 @@ COMMAND_REGISTRY: list[CommandDef] = [ cli_only=True, aliases=("gateway",)), CommandDef("copy", "Copy the last assistant response to clipboard", "Info", cli_only=True, args_hint="[number]"), - CommandDef("paste", "Check clipboard for an image and attach it", "Info", + CommandDef("paste", "Attach clipboard image or manage text paste shelf", "Info", cli_only=True), CommandDef("image", "Attach a local image file for your next prompt", "Info", cli_only=True, args_hint=""), diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 18b62bb48..964e1b522 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -1803,8 +1803,8 @@ def validate_requested_model( ) return { - "accepted": True, - "persist": True, + "accepted": False, + "persist": False, "recognized": False, "message": message, } @@ -1817,8 +1817,8 @@ def validate_requested_model( message += f"\n If this server expects `/v1`, try base URL: `{probe.get('suggested_base_url')}`" return { - "accepted": True, - "persist": True, + "accepted": False, + "persist": False, "recognized": False, "message": message, } @@ -1882,8 +1882,8 @@ def validate_requested_model( # but warn so typos don't silently break things. provider_label = _PROVIDER_LABELS.get(normalized, normalized) return { - "accepted": True, - "persist": True, + "accepted": False, + "persist": False, "recognized": False, "message": ( f"Could not reach the {provider_label} API to validate `{requested}`. " diff --git a/tests/hermes_cli/test_model_validation.py b/tests/hermes_cli/test_model_validation.py index be08ca034..3b83b81da 100644 --- a/tests/hermes_cli/test_model_validation.py +++ b/tests/hermes_cli/test_model_validation.py @@ -437,33 +437,33 @@ class TestValidateApiNotFound: def test_warning_includes_suggestions(self): result = _validate("anthropic/claude-opus-4.5") assert result["accepted"] is False + assert result["persist"] is False assert "Similar models" in result["message"] -# -- validate — API unreachable — accept and persist everything ---------------- +# -- validate — API unreachable — reject with guidance ---------------- class TestValidateApiFallback: - def test_any_model_accepted_when_api_down(self): + def test_any_model_rejected_when_api_down(self): result = _validate("anthropic/claude-opus-4.6", api_models=None) - assert result["accepted"] is True - assert result["persist"] is True + assert result["accepted"] is False + assert result["persist"] is False - def test_unknown_model_also_accepted_when_api_down(self): - """No hardcoded catalog gatekeeping — accept, persist, and warn.""" + def test_unknown_model_also_rejected_when_api_down(self): result = _validate("anthropic/claude-next-gen", api_models=None) - assert result["accepted"] is True - assert result["persist"] is True + assert result["accepted"] is False + assert result["persist"] is False assert "could not reach" in result["message"].lower() - def test_zai_model_accepted_when_api_down(self): + def test_zai_model_rejected_when_api_down(self): result = _validate("glm-5", provider="zai", api_models=None) - assert result["accepted"] is True - assert result["persist"] is True + assert result["accepted"] is False + assert result["persist"] is False - def test_unknown_provider_accepted_when_api_down(self): + def test_unknown_provider_rejected_when_api_down(self): result = _validate("some-model", provider="totally-unknown", api_models=None) - assert result["accepted"] is True - assert result["persist"] is True + assert result["accepted"] is False + assert result["persist"] is False def test_custom_endpoint_warns_with_probed_url_and_v1_hint(self): with patch( @@ -483,7 +483,7 @@ class TestValidateApiFallback: base_url="http://localhost:8000", ) - assert result["accepted"] is True - assert result["persist"] is True + assert result["accepted"] is False + assert result["persist"] is False assert "http://localhost:8000/v1/models" in result["message"] assert "http://localhost:8000/v1" in result["message"] diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 9ef4398e9..bee0d8811 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -167,11 +167,87 @@ def test_config_set_model_uses_live_switch_path(monkeypatch): assert seen["args"] == ("sid", "session-key", "new/model") +def test_config_set_model_global_persists(monkeypatch): + class _Agent: + provider = "openrouter" + model = "old/model" + base_url = "" + api_key = "sk-old" + + def switch_model(self, **kwargs): + return None + + result = types.SimpleNamespace( + success=True, + new_model="anthropic/claude-sonnet-4.6", + target_provider="anthropic", + api_key="sk-new", + base_url="https://api.anthropic.com", + api_mode="anthropic_messages", + warning_message="", + ) + seen = {} + saved = {} + + def _switch_model(**kwargs): + seen.update(kwargs) + return result + + server._sessions["sid"] = _session(agent=_Agent()) + monkeypatch.setattr("hermes_cli.model_switch.switch_model", _switch_model) + monkeypatch.setattr(server, "_restart_slash_worker", lambda session: None) + monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: None) + monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: saved.update(cfg)) + + resp = server.handle_request( + {"id": "1", "method": "config.set", "params": {"session_id": "sid", "key": "model", "value": "anthropic/claude-sonnet-4.6 --global"}} + ) + + assert resp["result"]["value"] == "anthropic/claude-sonnet-4.6" + assert seen["is_global"] is True + assert saved["model"]["default"] == "anthropic/claude-sonnet-4.6" + assert saved["model"]["provider"] == "anthropic" + assert saved["model"]["base_url"] == "https://api.anthropic.com" + + +def test_config_set_personality_rejects_unknown_name(monkeypatch): + monkeypatch.setattr(server, "_available_personalities", lambda cfg=None: {"helpful": "You are helpful."}) + resp = server.handle_request( + {"id": "1", "method": "config.set", "params": {"key": "personality", "value": "bogus"}} + ) + + assert "error" in resp + assert "Unknown personality" in resp["error"]["message"] + + +def test_config_set_personality_resets_history_and_returns_info(monkeypatch): + session = _session(agent=types.SimpleNamespace(), history=[{"role": "user", "text": "hi"}], history_version=4) + new_agent = types.SimpleNamespace(model="x") + emits = [] + + server._sessions["sid"] = session + monkeypatch.setattr(server, "_available_personalities", lambda cfg=None: {"helpful": "You are helpful."}) + monkeypatch.setattr(server, "_make_agent", lambda sid, key, session_id=None: new_agent) + monkeypatch.setattr(server, "_session_info", lambda agent: {"model": getattr(agent, "model", "?")}) + monkeypatch.setattr(server, "_restart_slash_worker", lambda session: None) + monkeypatch.setattr(server, "_emit", lambda *args: emits.append(args)) + + resp = server.handle_request( + {"id": "1", "method": "config.set", "params": {"session_id": "sid", "key": "personality", "value": "helpful"}} + ) + + assert resp["result"]["history_reset"] is True + assert resp["result"]["info"] == {"model": "x"} + assert session["history"] == [] + assert session["history_version"] == 5 + assert ("session.info", "sid", {"model": "x"}) in emits + + def test_session_compress_uses_compress_helper(monkeypatch): agent = types.SimpleNamespace() server._sessions["sid"] = _session(agent=agent) - monkeypatch.setattr(server, "_compress_session_history", lambda session: (2, {"total": 42})) + monkeypatch.setattr(server, "_compress_session_history", lambda session, focus_topic=None: (2, {"total": 42})) monkeypatch.setattr(server, "_session_info", lambda _agent: {"model": "x"}) with patch("tui_gateway.server._emit") as emit: @@ -266,6 +342,36 @@ def test_image_attach_appends_local_image(monkeypatch): assert len(server._sessions["sid"]["attached_images"]) == 1 +def test_command_dispatch_exec_nonzero_surfaces_error(monkeypatch): + monkeypatch.setattr(server, "_load_cfg", lambda: {"quick_commands": {"boom": {"type": "exec", "command": "boom"}}}) + monkeypatch.setattr( + server.subprocess, + "run", + lambda *args, **kwargs: types.SimpleNamespace(returncode=1, stdout="", stderr="failed"), + ) + + resp = server.handle_request({"id": "1", "method": "command.dispatch", "params": {"name": "boom"}}) + + assert "error" in resp + assert "failed" in resp["error"]["message"] + + +def test_plugins_list_surfaces_loader_error(monkeypatch): + with patch("hermes_cli.plugins.get_plugin_manager", side_effect=Exception("boom")): + resp = server.handle_request({"id": "1", "method": "plugins.list", "params": {}}) + + assert "error" in resp + assert "boom" in resp["error"]["message"] + + +def test_complete_slash_surfaces_completer_error(monkeypatch): + with patch("hermes_cli.commands.SlashCommandCompleter", side_effect=Exception("no completer")): + resp = server.handle_request({"id": "1", "method": "complete.slash", "params": {"text": "/mo"}}) + + assert "error" in resp + assert "no completer" in resp["error"]["message"] + + def test_input_detect_drop_attaches_image(monkeypatch): fake_cli = types.ModuleType("cli") fake_cli._detect_file_drop = lambda raw: { diff --git a/tui_gateway/server.py b/tui_gateway/server.py index f90b881e8..86f3617e2 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -183,10 +183,19 @@ def handle_request(req: dict) -> dict | None: def _sess(params, rid): - s = _sessions.get(params.get("session_id", "")) + s = _sessions.get(params.get("session_id") or "") return (s, None) if s else (None, _err(rid, 4001, "session not found")) +def _normalize_completion_path(path_part: str) -> str: + expanded = os.path.expanduser(path_part) + if os.name != "nt": + normalized = expanded.replace("\\", "/") + if len(normalized) >= 3 and normalized[1] == ":" and normalized[2] == "/" and normalized[0].isalpha(): + return f"/mnt/{normalized[0].lower()}/{normalized[3:]}" + return expanded + + # ── Config I/O ──────────────────────────────────────────────────────── def _load_cfg() -> dict: @@ -327,38 +336,75 @@ def _restart_slash_worker(session: dict): session["slash_worker"] = None -def _apply_model_switch(sid: str, session: dict, raw_input: str) -> dict: - agent = session.get("agent") - if not agent: - os.environ["HERMES_MODEL"] = raw_input - return {"value": raw_input, "warning": ""} +def _persist_model_switch(result) -> None: + from hermes_cli.config import save_config - from hermes_cli.model_switch import switch_model + cfg = _load_cfg() + model_cfg = cfg.get("model") + if not isinstance(model_cfg, dict): + model_cfg = {} + cfg["model"] = model_cfg + + model_cfg["default"] = result.new_model + model_cfg["provider"] = result.target_provider + if result.base_url: + model_cfg["base_url"] = result.base_url + else: + model_cfg.pop("base_url", None) + save_config(cfg) + + +def _apply_model_switch(sid: str, session: dict, raw_input: str) -> dict: + from hermes_cli.model_switch import parse_model_flags, switch_model + from hermes_cli.runtime_provider import resolve_runtime_provider + + model_input, explicit_provider, persist_global = parse_model_flags(raw_input) + if not model_input: + raise ValueError("model value required") + + agent = session.get("agent") + if agent: + current_provider = getattr(agent, "provider", "") or "" + current_model = getattr(agent, "model", "") or "" + current_base_url = getattr(agent, "base_url", "") or "" + current_api_key = getattr(agent, "api_key", "") or "" + else: + runtime = resolve_runtime_provider(requested=None) + current_provider = str(runtime.get("provider", "") or "") + current_model = _resolve_model() + current_base_url = str(runtime.get("base_url", "") or "") + current_api_key = str(runtime.get("api_key", "") or "") result = switch_model( - raw_input=raw_input, - current_provider=getattr(agent, "provider", "") or "", - current_model=getattr(agent, "model", "") or "", - current_base_url=getattr(agent, "base_url", "") or "", - current_api_key=getattr(agent, "api_key", "") or "", + raw_input=model_input, + current_provider=current_provider, + current_model=current_model, + current_base_url=current_base_url, + current_api_key=current_api_key, + is_global=persist_global, + explicit_provider=explicit_provider, ) if not result.success: raise ValueError(result.error_message or "model switch failed") - agent.switch_model( - new_model=result.new_model, - new_provider=result.target_provider, - api_key=result.api_key, - base_url=result.base_url, - api_mode=result.api_mode, - ) + if agent: + agent.switch_model( + new_model=result.new_model, + new_provider=result.target_provider, + api_key=result.api_key, + base_url=result.base_url, + api_mode=result.api_mode, + ) + _restart_slash_worker(session) + _emit("session.info", sid, _session_info(agent)) + os.environ["HERMES_MODEL"] = result.new_model - _restart_slash_worker(session) - _emit("session.info", sid, _session_info(agent)) + if persist_global: + _persist_model_switch(result) return {"value": result.new_model, "warning": result.warning_message or ""} -def _compress_session_history(session: dict) -> tuple[int, dict]: +def _compress_session_history(session: dict, focus_topic: str | None = None) -> tuple[int, dict]: from agent.model_metadata import estimate_messages_tokens_rough agent = session["agent"] @@ -370,6 +416,7 @@ def _compress_session_history(session: dict) -> tuple[int, dict]: history, getattr(agent, "_cached_system_prompt", "") or "", approx_tokens=approx_tokens, + focus_topic=focus_topic or None, ) session["history"] = compressed session["history_version"] = int(session.get("history_version", 0)) + 1 @@ -617,21 +664,91 @@ def _resolve_personality_prompt(cfg: dict) -> str: if not name or name in ("default", "none", "neutral"): return "" try: - from hermes_cli.config import load_config as _load_full_cfg - personalities = _load_full_cfg().get("agent", {}).get("personalities", {}) + from cli import load_cli_config + + personalities = load_cli_config().get("agent", {}).get("personalities", {}) except Exception: - personalities = cfg.get("agent", {}).get("personalities", {}) + try: + from hermes_cli.config import load_config as _load_full_cfg + + personalities = _load_full_cfg().get("agent", {}).get("personalities", {}) + except Exception: + personalities = cfg.get("agent", {}).get("personalities", {}) pval = personalities.get(name) if pval is None: return "" - if isinstance(pval, dict): - parts = [pval.get("system_prompt", "")] - if pval.get("tone"): - parts.append(f'Tone: {pval["tone"]}') - if pval.get("style"): - parts.append(f'Style: {pval["style"]}') + return _render_personality_prompt(pval) + + +def _render_personality_prompt(value) -> str: + if isinstance(value, dict): + parts = [value.get("system_prompt", "")] + if value.get("tone"): + parts.append(f'Tone: {value["tone"]}') + if value.get("style"): + parts.append(f'Style: {value["style"]}') return "\n".join(p for p in parts if p) - return str(pval) + return str(value) + + +def _available_personalities(cfg: dict | None = None) -> dict: + try: + from cli import load_cli_config + + return load_cli_config().get("agent", {}).get("personalities", {}) or {} + except Exception: + try: + from hermes_cli.config import load_config as _load_full_cfg + + return _load_full_cfg().get("agent", {}).get("personalities", {}) or {} + except Exception: + cfg = cfg or _load_cfg() + return cfg.get("agent", {}).get("personalities", {}) or {} + + +def _validate_personality(value: str, cfg: dict | None = None) -> tuple[str, str]: + raw = str(value or "").strip() + name = raw.lower() + if not name or name in ("none", "default", "neutral"): + return "", "" + + personalities = _available_personalities(cfg) + if name not in personalities: + names = sorted(personalities) + available = ", ".join(f"`{n}`" for n in names) + base = f"Unknown personality: `{raw}`." + if available: + base += f"\n\nAvailable: `none`, {available}" + else: + base += "\n\nNo personalities configured." + raise ValueError(base) + + return name, _render_personality_prompt(personalities[name]) + + +def _apply_personality_to_session(sid: str, session: dict, new_prompt: str) -> tuple[bool, dict | None]: + if not session: + 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) + return True, info + except Exception: + if session.get("agent"): + agent = session["agent"] + agent.ephemeral_system_prompt = new_prompt or None + agent._cached_system_prompt = None + info = _session_info(agent) + _emit("session.info", sid, info) + return False, info + return False, None def _make_agent(sid: str, key: str, session_id: str | None = None): @@ -893,9 +1010,11 @@ def _(rid, params: dict) -> dict: return err try: with session["history_lock"]: - removed, usage = _compress_session_history(session) - _emit("session.info", params.get("session_id", ""), _session_info(session["agent"])) - return _ok(rid, {"status": "compressed", "removed": removed, "usage": usage}) + removed, usage = _compress_session_history(session, str(params.get("focus_topic", "") or "").strip()) + messages = list(session.get("history", [])) + info = _session_info(session["agent"]) + _emit("session.info", params.get("session_id", ""), info) + return _ok(rid, {"status": "compressed", "removed": removed, "usage": usage, "info": info, "messages": messages}) except Exception as e: return _err(rid, 5005, str(e)) @@ -906,7 +1025,7 @@ def _(rid, params: dict) -> dict: if err: return err import time as _time - filename = f"hermes_conversation_{_time.strftime('%Y%m%d_%H%M%S')}.json" + filename = os.path.abspath(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", [])}, @@ -916,6 +1035,27 @@ def _(rid, params: dict) -> dict: return _err(rid, 5011, str(e)) +@method("session.close") +def _(rid, params: dict) -> dict: + sid = params.get("session_id", "") + session = _sessions.pop(sid, None) + if not session: + return _ok(rid, {"closed": False}) + try: + from tools.approval import unregister_gateway_notify + + unregister_gateway_notify(session["session_key"]) + except Exception: + pass + try: + worker = session.get("slash_worker") + if worker: + worker.close() + except Exception: + pass + return _ok(rid, {"closed": True}) + + @method("session.branch") def _(rid, params: dict) -> dict: session, err = _sess(params, rid) @@ -1087,6 +1227,7 @@ def _(rid, params: dict) -> dict: # Save-first: mirrors CLI keybinding path; more robust than has_image() precheck if not save_clipboard_image(img_path): + session["image_counter"] = max(0, session["image_counter"] - 1) msg = "Clipboard has image but extraction failed" if has_clipboard_image() else "No image found in clipboard" return _ok(rid, {"attached": False, "message": msg}) @@ -1182,6 +1323,9 @@ def _(rid, params: dict) -> dict: @method("prompt.background") def _(rid, params: dict) -> dict: + session, err = _sess(params, rid) + if err: + return err text, parent = params.get("text", ""), params.get("session_id", "") if not text: return _err(rid, 4012, "text required") @@ -1275,8 +1419,7 @@ def _(rid, params: dict) -> dict: if session: result = _apply_model_switch(params.get("session_id", ""), session, value) else: - os.environ["HERMES_MODEL"] = value - result = {"value": value, "warning": ""} + result = _apply_model_switch("", {"agent": None}, value) return _ok(rid, {"key": key, "value": result["value"], "warning": result["warning"]}) except Exception as e: return _err(rid, 5001, str(e)) @@ -1368,25 +1511,12 @@ def _(rid, params: dict) -> dict: nv = value _save_cfg(cfg) elif key == "personality": - pname = value if value not in ("none", "default", "neutral") else "" - _write_config_key("display.personality", pname) - cfg = _load_cfg() - new_prompt = _resolve_personality_prompt(cfg) - _write_config_key("agent.system_prompt", new_prompt) - nv = value sid_key = params.get("session_id", "") - if session: - try: - new_agent = _make_agent(sid_key, 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 - except Exception: - if session.get("agent"): - agent = session["agent"] - agent.ephemeral_system_prompt = new_prompt or None - agent._cached_system_prompt = None + pname, new_prompt = _validate_personality(str(value or ""), cfg) + _write_config_key("display.personality", pname) + _write_config_key("agent.system_prompt", new_prompt) + nv = str(value or "default") + history_reset, info = _apply_personality_to_session(sid_key, session, new_prompt) else: _write_config_key(f"display.{key}", value) nv = value @@ -1394,7 +1524,9 @@ def _(rid, params: dict) -> dict: _emit("skin.changed", "", resolve_skin()) resp = {"key": key, "value": nv} if key == "personality": - resp["cleared"] = True + resp["history_reset"] = history_reset + if info is not None: + resp["info"] = info return _ok(rid, resp) except Exception as e: return _err(rid, 5001, str(e)) @@ -1425,6 +1557,11 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"value": _load_cfg().get("display", {}).get("skin", "default")}) if key == "personality": return _ok(rid, {"value": _load_cfg().get("display", {}).get("personality", "default")}) + if key == "reasoning": + cfg = _load_cfg() + effort = str(cfg.get("agent", {}).get("reasoning_effort", "medium") or "medium") + display = "show" if bool(cfg.get("display", {}).get("show_reasoning", False)) else "hide" + return _ok(rid, {"value": effort, "display": display}) if key == "mtime": cfg_path = _hermes_home / "config.yaml" try: @@ -1510,14 +1647,15 @@ def _(rid, params: dict) -> dict: cat_map[cat].append([name, desc]) skill_count = 0 + warning = "" try: from agent.skill_commands import scan_skill_commands for k, info in sorted(scan_skill_commands().items()): d = str(info.get("description", "Skill")) all_pairs.append([k, d[:120] + ("…" if len(d) > 120 else "")]) skill_count += 1 - except Exception: - pass + except Exception as e: + warning = f"skill discovery unavailable: {e}" for cat in cat_order: categories.append({"name": cat, "pairs": cat_map[cat]}) @@ -1529,6 +1667,7 @@ def _(rid, params: dict) -> dict: "canon": canon, "categories": categories, "skill_count": skill_count, + "warning": warning, }) except Exception as e: return _err(rid, 5020, str(e)) @@ -1611,7 +1750,10 @@ def _(rid, params: dict) -> dict: 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]}) + output = ((r.stdout or "") + ("\n" if r.stdout and r.stderr else "") + (r.stderr or "")).strip()[:4000] + if r.returncode != 0: + return _err(rid, 4018, output or f"quick command failed with exit code {r.returncode}") + return _ok(rid, {"type": "exec", "output": output}) if qc.get("type") == "alias": return _ok(rid, {"type": "alias", "target": qc.get("target", "")}) @@ -1692,15 +1834,18 @@ def _(rid, params: dict) -> dict: prefix_tag = "" path_part = query if not is_context else query - expanded = os.path.expanduser(path_part) + expanded = _normalize_completion_path(path_part) if expanded.endswith("/"): search_dir, match = expanded, "" else: search_dir = os.path.dirname(expanded) or "." match = os.path.basename(expanded) + if not os.path.isdir(search_dir): + return _ok(rid, {"items": []}) + match_lower = match.lower() - for entry in sorted(os.listdir(search_dir))[:200]: + for entry in sorted(os.listdir(search_dir)): if match and not entry.lower().startswith(match_lower): continue if is_context and not prefix_tag and entry.startswith("."): @@ -1725,8 +1870,8 @@ def _(rid, params: dict) -> dict: items.append({"text": text, "display": entry + suffix, "meta": "dir" if is_dir else ""}) if len(items) >= 30: break - except Exception: - pass + except Exception as e: + return _err(rid, 5021, str(e)) return _ok(rid, {"items": items}) @@ -1742,39 +1887,83 @@ def _(rid, params: dict) -> dict: from prompt_toolkit.document import Document from prompt_toolkit.formatted_text import to_plain_text - completer = SlashCommandCompleter() + from agent.skill_commands import get_skill_commands + + completer = SlashCommandCompleter(skill_commands_provider=lambda: get_skill_commands()) doc = Document(text, len(text)) items = [ {"text": c.text, "display": c.display or c.text, "meta": to_plain_text(c.display_meta) if c.display_meta else ""} for c in completer.get_completions(doc, None) ][:30] + text_lower = text.lower() + extras = [ + {"text": "/compact", "display": "/compact", "meta": "Toggle compact display mode"}, + {"text": "/logs", "display": "/logs", "meta": "Show recent gateway log lines"}, + ] + for extra in extras: + if extra["text"].startswith(text_lower) and not any(item["text"] == extra["text"] for item in items): + items.append(extra) return _ok(rid, {"items": items, "replace_from": text.rfind(" ") + 1 if " " in text else 1}) - except Exception: - return _ok(rid, {"items": []}) + except Exception as e: + return _err(rid, 5020, str(e)) + + +@method("model.options") +def _(rid, params: dict) -> dict: + try: + from hermes_cli.model_switch import list_authenticated_providers + from hermes_cli.models import provider_model_ids + + session = _sessions.get(params.get("session_id", "")) + agent = session.get("agent") if session else None + cfg = _load_cfg() + current_provider = getattr(agent, "provider", "") or "" + current_model = getattr(agent, "model", "") or _resolve_model() + providers = list_authenticated_providers( + current_provider=current_provider, + user_providers=cfg.get("providers") if isinstance(cfg.get("providers"), dict) else {}, + custom_providers=cfg.get("custom_providers") if isinstance(cfg.get("custom_providers"), list) else [], + max_models=50, + ) + for provider in providers: + try: + models = provider_model_ids(provider.get("slug")) + if models: + provider["models"] = models + provider["total_models"] = len(models) + except Exception as e: + provider["warning"] = f"model catalog unavailable: {e}" + return _ok(rid, {"providers": providers, "model": current_model, "provider": current_provider}) + except Exception as e: + return _err(rid, 5033, str(e)) # ── Methods: slash.exec ────────────────────────────────────────────── -def _mirror_slash_side_effects(sid: str, session: dict, command: str): +def _mirror_slash_side_effects(sid: str, session: dict, command: str) -> str: """Apply side effects that must also hit the gateway's live agent.""" parts = command.lstrip("/").split(None, 1) if not parts: - return + return "" name, arg, agent = parts[0], (parts[1].strip() if len(parts) > 1 else ""), session.get("agent") try: if name == "model" and arg and agent: - _apply_model_switch(sid, session, arg) - elif name in ("personality", "prompt") and agent: + result = _apply_model_switch(sid, session, arg) + return result.get("warning", "") + elif name == "personality" and arg and agent: + _, new_prompt = _validate_personality(arg, _load_cfg()) + _apply_personality_to_session(sid, session, new_prompt) + elif name == "prompt" and agent: cfg = _load_cfg() new_prompt = cfg.get("agent", {}).get("system_prompt", "") or "" agent.ephemeral_system_prompt = new_prompt or None agent._cached_system_prompt = None elif name == "compress" and agent: with session["history_lock"]: - _compress_session_history(session) + _compress_session_history(session, arg) _emit("session.info", sid, _session_info(agent)) elif name == "fast" and agent: mode = arg.lower() @@ -1788,8 +1977,9 @@ def _mirror_slash_side_effects(sid: str, session: dict, command: str): elif name == "stop": from tools.process_registry import ProcessRegistry ProcessRegistry().kill_all() - except Exception: - pass + except Exception as e: + return f"live session sync failed: {e}" + return "" @method("slash.exec") @@ -1812,8 +2002,11 @@ def _(rid, params: dict) -> dict: try: output = worker.run(cmd) - _mirror_slash_side_effects(params.get("session_id", ""), session, cmd) - return _ok(rid, {"output": output or "(no output)"}) + warning = _mirror_slash_side_effects(params.get("session_id", ""), session, cmd) + payload = {"output": output or "(no output)"} + if warning: + payload["warning"] = warning + return _ok(rid, payload) except Exception as e: try: worker.close() @@ -1829,9 +2022,14 @@ def _(rid, params: dict) -> dict: def _(rid, params: dict) -> dict: action = params.get("action", "status") if action == "status": - return _ok(rid, {"enabled": os.environ.get("HERMES_VOICE", "0") == "1"}) + env = os.environ.get("HERMES_VOICE", "").strip() + if env in {"0", "1"}: + return _ok(rid, {"enabled": env == "1"}) + return _ok(rid, {"enabled": bool(_load_cfg().get("display", {}).get("voice_enabled", False))}) if action in ("on", "off"): - os.environ["HERMES_VOICE"] = "1" if action == "on" else "0" + enabled = action == "on" + os.environ["HERMES_VOICE"] = "1" if enabled else "0" + _write_config_key("display.voice_enabled", enabled) return _ok(rid, {"enabled": action == "on"}) return _err(rid, 4013, f"unknown voice action: {action}") @@ -1965,12 +2163,34 @@ def _(rid, params: dict) -> dict: 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: + import urllib.request + from urllib.parse import urlparse from tools.browser_tool import cleanup_all_browsers + + parsed = urlparse(url if "://" in url else f"http://{url}") + if parsed.scheme not in {"http", "https", "ws", "wss"}: + return _err(rid, 4015, f"unsupported browser url: {url}") + probe_root = ( + f"{'https' if parsed.scheme == 'wss' else 'http' if parsed.scheme == 'ws' else parsed.scheme}://{parsed.netloc}" + ) + probe_urls = [f"{probe_root.rstrip('/')}/json/version", f"{probe_root.rstrip('/')}/json"] + ok = False + for probe in probe_urls: + try: + with urllib.request.urlopen(probe, timeout=2.0) as resp: + if 200 <= getattr(resp, "status", 200) < 300: + ok = True + break + except Exception: + continue + if not ok: + return _err(rid, 5031, f"could not reach browser CDP at {url}") + + os.environ["BROWSER_CDP_URL"] = url cleanup_all_browsers() - except Exception: - pass + except Exception as e: + return _err(rid, 5031, str(e)) return _ok(rid, {"connected": True, "url": url}) if action == "disconnect": os.environ.pop("BROWSER_CDP_URL", None) @@ -1990,8 +2210,8 @@ def _(rid, params: dict) -> dict: 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": []}) + except Exception as e: + return _err(rid, 5032, str(e)) @method("config.show") diff --git a/ui-tui/.gitignore b/ui-tui/.gitignore index fc8abe696..c5323f872 100644 --- a/ui-tui/.gitignore +++ b/ui-tui/.gitignore @@ -1,3 +1,4 @@ dist/ node_modules/ src/*.js +docs/ \ No newline at end of file diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index e9152f1b3..6e69ba42c 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -9,6 +9,8 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { Banner, Panel, SessionPanel } from './components/branding.js' import { MaskedPrompt } from './components/maskedPrompt.js' import { MessageLine } from './components/messageLine.js' +import { ModelPicker } from './components/modelPicker.js' +import { PasteShelf } from './components/pasteShelf.js' import { ApprovalPrompt, ClarifyPrompt } from './components/prompts.js' import { QueuedMessages } from './components/queuedMessages.js' import { SessionPicker } from './components/sessionPicker.js' @@ -126,6 +128,16 @@ const imageTokenMeta = (info: { height?: number; token_estimate?: number; width? return [dims, tok].filter(Boolean).join(' · ') } +const looksLikeSlashCommand = (text: string) => { + if (!text.startsWith('/')) { + return false + } + + const first = text.split(/\s+/, 1)[0] || '' + + return !first.slice(1).includes('/') +} + const toTranscriptMessages = (rows: unknown): Msg[] => { if (!Array.isArray(rows)) { return [] @@ -347,6 +359,7 @@ export function App({ gw }: { gw: GatewayClient }) { const [approval, setApproval] = useState(null) const [sudo, setSudo] = useState(null) const [secret, setSecret] = useState(null) + const [modelPicker, setModelPicker] = useState(false) const [picker, setPicker] = useState(false) const [reasoning, setReasoning] = useState('') const [reasoningActive, setReasoningActive] = useState(false) @@ -386,10 +399,12 @@ export function App({ gw }: { gw: GatewayClient }) { const reasoningStreamingTimerRef = useRef | null>(null) const statusTimerRef = useRef | null>(null) const busyRef = useRef(busy) + const sidRef = useRef(sid) const onEventRef = useRef<(ev: GatewayEvent) => void>(() => {}) const configMtimeRef = useRef(0) colsRef.current = cols busyRef.current = busy + sidRef.current = sid reasoningRef.current = reasoning // ── Hooks ──────────────────────────────────────────────────────── @@ -433,7 +448,7 @@ export function App({ gw }: { gw: GatewayClient }) { ) function blocked() { - return !!(clarify || approval || picker || secret || sudo || pager) + return !!(clarify || approval || modelPicker || picker || secret || sudo || pager) } const empty = !messages.length @@ -489,6 +504,15 @@ export function App({ gw }: { gw: GatewayClient }) { [appendMessage] ) + const maybeWarn = useCallback( + (value: any) => { + if (value?.warning) { + sys(`warning: ${value.warning}`) + } + }, + [sys] + ) + const pushActivity = useCallback((text: string, tone: ActivityItem['tone'] = 'info', replaceLabel?: string) => { setActivity(prev => { const base = replaceLabel ? prev.filter(a => !sameToolTrailGroup(replaceLabel, a.text)) : prev @@ -553,25 +577,29 @@ export function App({ gw }: { gw: GatewayClient }) { setTrail(turnToolsRef.current.filter(l => !sameToolTrailGroup(label, l))) setTurnTrail(turnToolsRef.current) - gw.request('clarify.respond', { answer, request_id: clarify.requestId }).catch(() => {}) + rpc('clarify.respond', { answer, request_id: clarify.requestId }).then(r => { + if (!r) { + return + } - if (answer) { - persistedToolLabelsRef.current.add(label) - appendMessage({ - role: 'system', - text: '', - kind: 'trail', - tools: [buildToolTrailLine('clarify', clarify.question)] - }) - appendMessage({ role: 'user', text: answer }) - } else { - sys('prompt cancelled') - } + if (answer) { + persistedToolLabelsRef.current.add(label) + appendMessage({ + role: 'system', + text: '', + kind: 'trail', + tools: [buildToolTrailLine('clarify', clarify.question)] + }) + appendMessage({ role: 'user', text: answer }) + setStatus('running…') + } else { + sys('prompt cancelled') + } - setClarify(null) - setStatus('running…') + setClarify(null) + }) }, - [appendMessage, clarify, gw, sys] + [appendMessage, clarify, rpc, sys] ) useEffect(() => { @@ -650,6 +678,7 @@ export function App({ gw }: { gw: GatewayClient }) { setVoiceRecording(false) setVoiceProcessing(false) setSid(null as any) // will be set by caller + setInfo(null) setHistoryItems([]) setMessages([]) setPastes([]) @@ -661,11 +690,64 @@ export function App({ gw }: { gw: GatewayClient }) { protocolWarnedRef.current = false } + const resetVisibleHistory = (info: SessionInfo | null = null) => { + idle() + setReasoning('') + setMessages([]) + setHistoryItems(info ? [introMsg(info)] : []) + setInfo(info) + setUsage(info?.usage ? { ...ZERO, ...info.usage } : ZERO) + setActivity([]) + setLastUserMsg('') + turnToolsRef.current = [] + persistedToolLabelsRef.current.clear() + } + + const trimLastExchange = (items: Msg[]) => { + const q = [...items] + + while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') { + q.pop() + } + + if (q.at(-1)?.role === 'user') { + q.pop() + } + + return q + } + + const guardBusySessionSwitch = useCallback( + (what = 'switch sessions') => { + if (!busyRef.current) { + return false + } + + sys(`interrupt the current turn before trying to ${what}`) + + return true + }, + [sys] + ) + + const closeSession = useCallback( + (targetSid?: string | null) => { + if (!targetSid) { + return Promise.resolve(null) + } + + return rpc('session.close', { session_id: targetSid }) + }, + [rpc] + ) + // ── Session management ─────────────────────────────────────────── const newSession = useCallback( - (msg?: string) => - rpc('session.create', { cols: colsRef.current }).then((r: any) => { + async (msg?: string) => { + await closeSession(sidRef.current) + + return rpc('session.create', { cols: colsRef.current }).then((r: any) => { if (!r) { setStatus('ready') @@ -696,45 +778,49 @@ export function App({ gw }: { gw: GatewayClient }) { if (msg) { sys(msg) } - }), - [rpc, sys] + }) + }, + [closeSession, rpc, sys] ) const resumeById = useCallback( (id: string) => { setPicker(false) setStatus('resuming…') - gw.request('session.resume', { cols: colsRef.current, session_id: id }) - .then((raw: any) => { - const r = asRpcResult(raw) + closeSession(sidRef.current === id ? null : sidRef.current).then(() => + gw + .request('session.resume', { cols: colsRef.current, session_id: id }) + .then((raw: any) => { + const r = asRpcResult(raw) - if (!r) { - sys('error: invalid response: session.resume') + if (!r) { + sys('error: invalid response: session.resume') + setStatus('ready') + + return + } + + resetSession() + setSid(r.session_id) + setSessionStartedAt(Date.now()) + setInfo(r.info ?? null) + const resumed = toTranscriptMessages(r.messages) + + if (r.info?.usage) { + setUsage(prev => ({ ...prev, ...r.info.usage })) + } + + setMessages(resumed) + setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed) setStatus('ready') - - return - } - - resetSession() - setSid(r.session_id) - setSessionStartedAt(Date.now()) - setInfo(r.info ?? null) - const resumed = toTranscriptMessages(r.messages) - - if (r.info?.usage) { - setUsage(prev => ({ ...prev, ...r.info.usage })) - } - - setMessages(resumed) - setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed) - setStatus('ready') - }) - .catch((e: Error) => { - sys(`error: ${e.message}`) - setStatus('ready') - }) + }) + .catch((e: Error) => { + sys(`error: ${e.message}`) + setStatus('ready') + }) + ) }, - [gw, sys] + [closeSession, gw, sys] ) // ── Paste pipeline ─────────────────────────────────────────────── @@ -815,13 +901,13 @@ export function App({ gw }: { gw: GatewayClient }) { return null } - if (bracketed) { - void paste(true) - } - const cleanedText = stripTrailingPasteNewlines(text) - if (!cleanedText) { + if (!cleanedText || !/[^\n]/.test(cleanedText)) { + if (bracketed) { + void paste(true) + } + return null } @@ -904,7 +990,7 @@ export function App({ gw }: { gw: GatewayClient }) { }) } - gw.request('input.detect_drop', { session_id: sid, text: payload.text }) + gw.request('input.detect_drop', { session_id: sid, text }) .then((r: any) => { if (r?.matched) { if (r.is_image) { @@ -1029,7 +1115,13 @@ export function App({ gw }: { gw: GatewayClient }) { const dispatchSubmission = useCallback( (full: string) => { - if (!full.trim() || !sid) { + if (!full.trim()) { + return + } + + if (!sid) { + sys('session not ready yet') + return } @@ -1040,7 +1132,7 @@ export function App({ gw }: { gw: GatewayClient }) { historyDraftRef.current = '' } - if (full.startsWith('/')) { + if (looksLikeSlashCommand(full)) { appendMessage({ role: 'system', text: full, kind: 'slash' }) pushHistory(full) slashRef.current(full) @@ -1137,17 +1229,34 @@ export function App({ gw }: { gw: GatewayClient }) { if (clarify) { answerClarify('') } else if (approval) { - gw.request('approval.respond', { choice: 'deny', session_id: sid }).catch(() => {}) - setApproval(null) - sys('denied') + rpc('approval.respond', { choice: 'deny', session_id: sid }).then(r => { + if (!r) { + return + } + + setApproval(null) + sys('denied') + }) } else if (sudo) { - gw.request('sudo.respond', { request_id: sudo.requestId, password: '' }).catch(() => {}) - setSudo(null) - sys('sudo cancelled') + rpc('sudo.respond', { request_id: sudo.requestId, password: '' }).then(r => { + if (!r) { + return + } + + setSudo(null) + sys('sudo cancelled') + }) } else if (secret) { - gw.request('secret.respond', { request_id: secret.requestId, value: '' }).catch(() => {}) - setSecret(null) - sys('secret entry cancelled') + rpc('secret.respond', { request_id: secret.requestId, value: '' }).then(r => { + if (!r) { + return + } + + setSecret(null) + sys('secret entry cancelled') + }) + } else if (modelPicker) { + setModelPicker(false) } else if (picker) { setPicker(false) } @@ -1164,11 +1273,12 @@ export function App({ gw }: { gw: GatewayClient }) { return } - if (!inputBuf.length && key.tab && completions.length) { + if (key.tab && completions.length) { const row = completions[compIdx] - if (row) { - setInput(input.slice(0, compReplace) + row.text) + if (row?.text) { + const text = input.startsWith('/') && row.text.startsWith('/') && compReplace > 0 ? row.text.slice(1) : row.text + setInput(input.slice(0, compReplace) + text) } return @@ -1255,6 +1365,10 @@ export function App({ gw }: { gw: GatewayClient }) { } if (ctrl(key, ch, 'l')) { + if (guardBusySessionSwitch()) { + return + } + setStatus('forging session…') newSession() @@ -1319,6 +1433,10 @@ export function App({ gw }: { gw: GatewayClient }) { const onEvent = useCallback( (ev: GatewayEvent) => { + if (ev.session_id && sidRef.current && ev.session_id !== sidRef.current && !ev.type.startsWith('gateway.')) { + return + } + const p = ev.payload as any switch (ev.type) { @@ -1342,8 +1460,12 @@ export function App({ gw }: { gw: GatewayClient }) { skillCount: (r.skill_count ?? 0) as number, sub: (r.sub ?? {}) as Record }) + + if (r.warning) { + pushActivity(String(r.warning), 'warn') + } }) - .catch(() => {}) + .catch((e: unknown) => pushActivity(`command catalog unavailable: ${rpcErrorMessage(e)}`, 'warn')) if (STARTUP_RESUME_ID) { setStatus('resuming…') @@ -1397,6 +1519,10 @@ export function App({ gw }: { gw: GatewayClient }) { break case 'thinking.delta': + if (p && Object.prototype.hasOwnProperty.call(p, 'text')) { + setStatus(p.text ? String(p.text) : busyRef.current ? 'running…' : 'ready') + } + break case 'message.start': @@ -1438,19 +1564,43 @@ export function App({ gw }: { gw: GatewayClient }) { case 'gateway.stderr': if (p?.line) { - pushActivity(String(p.line).slice(0, 120), 'error') + const line = String(p.line).slice(0, 120) + const tone = /\b(error|traceback|exception|failed|spawn)\b/i.test(line) ? 'error' : 'warn' + pushActivity(line, tone) } break + case 'gateway.start_timeout': + 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': setStatus('protocol warning') + if (statusTimerRef.current) { + clearTimeout(statusTimerRef.current) + } + + statusTimerRef.current = setTimeout(() => { + statusTimerRef.current = null + setStatus(busyRef.current ? 'running…' : 'ready') + }, 4000) + if (!protocolWarnedRef.current) { protocolWarnedRef.current = true pushActivity('protocol noise detected · /logs to inspect', 'warn') } + if (p?.preview) { + pushActivity(`protocol noise: ${String(p.preview).slice(0, 120)}`, 'warn') + } + break case 'reasoning.delta': @@ -1663,11 +1813,13 @@ export function App({ gw }: { gw: GatewayClient }) { bellOnComplete, dequeue, endReasoningPhase, + gw, newSession, pruneTransient, pulseReasoningStreaming, pushActivity, pushTrail, + rpc, sendQueued, sys, stdout @@ -1681,7 +1833,10 @@ export function App({ gw }: { gw: GatewayClient }) { const exitHandler = () => { setStatus('gateway exited') - exit() + setSid(null) + setBusy(false) + pushActivity('gateway exited · /logs to inspect', 'error') + sys('error: gateway exited') } gw.on('event', handler) @@ -1691,14 +1846,16 @@ export function App({ gw }: { gw: GatewayClient }) { return () => { gw.off('event', handler) gw.off('exit', exitHandler) + gw.kill() } - }, [gw, exit]) + }, [gw, pushActivity, sys]) // ── Slash commands ─────────────────────────────────────────────── const slash = useCallback( (cmd: string): boolean => { - const [name, ...rest] = cmd.slice(1).split(/\s+/) + const [rawName, ...rest] = cmd.slice(1).split(/\s+/) + const name = rawName.toLowerCase() const arg = rest.join(' ') switch (name) { @@ -1729,18 +1886,30 @@ export function App({ gw }: { gw: GatewayClient }) { return true case 'clear': + if (guardBusySessionSwitch('switch sessions')) { + return true + } + setStatus('forging session…') newSession() return true case 'new': + if (guardBusySessionSwitch('switch sessions')) { + return true + } + setStatus('forging session…') newSession('new session started') return true case 'resume': + if (guardBusySessionSwitch('switch sessions')) { + return true + } + if (arg) { resumeById(arg) } else { @@ -1750,13 +1919,33 @@ export function App({ gw }: { gw: GatewayClient }) { return true case 'compact': - setCompact(c => (arg ? true : !c)) - sys(arg ? `compact on, focus: ${arg}` : `compact ${compact ? 'off' : 'on'}`) + if (arg && !['on', 'off', 'toggle'].includes(arg.trim().toLowerCase())) { + sys('usage: /compact [on|off|toggle]') + + return true + } + + { + const mode = arg.trim().toLowerCase() + setCompact(current => { + const next = mode === 'on' ? true : mode === 'off' ? false : !current + queueMicrotask(() => sys(`compact ${next ? 'on' : 'off'}`)) + + return next + }) + } return true case 'copy': { const all = messages.filter(m => m.role === 'assistant') - const target = all[arg ? Math.min(parseInt(arg), all.length) - 1 : all.length - 1] + + if (arg && Number.isNaN(parseInt(arg, 10))) { + sys('usage: /copy [number]') + + return true + } + + const target = all[arg ? Math.min(parseInt(arg, 10), all.length) - 1 : all.length - 1] if (!target) { sys('nothing to copy') @@ -1765,7 +1954,7 @@ export function App({ gw }: { gw: GatewayClient }) { } writeOsc52Clipboard(target.text) - sys('copied to clipboard') + sys('sent OSC52 copy sequence (terminal support required)') return true } @@ -1815,7 +2004,7 @@ export function App({ gw }: { gw: GatewayClient }) { return true } - const re = new RegExp(`\\s*\\[\\[paste:${id}\\]\\]\\s*`, 'g') + const re = new RegExp(`\\s*\\[\\[paste:${id}(?:[^\\n]*?)\\]\\]\\s*`, 'g') setPastes(prev => prev.filter(p => p.id !== id)) setInput(v => stripTokens(v, re)) setInputBuf(prev => prev.map(l => stripTokens(l, re)).filter(Boolean)) @@ -1854,8 +2043,12 @@ export function App({ gw }: { gw: GatewayClient }) { case 'statusbar': case 'sb': - setStatusBar(v => !v) - sys(`status bar ${statusBar ? 'off' : 'on'}`) + setStatusBar(current => { + const next = !current + queueMicrotask(() => sys(`status bar ${next ? 'on' : 'off'}`)) + + return next + }) return true @@ -1873,6 +2066,8 @@ export function App({ gw }: { gw: GatewayClient }) { case 'undo': if (!sid) { + sys('nothing to undo') + return true } @@ -1882,19 +2077,8 @@ export function App({ gw }: { gw: GatewayClient }) { } if (r.removed > 0) { - setMessages(prev => { - const q = [...prev] - - while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') { - q.pop() - } - - if (q.at(-1)?.role === 'user') { - q.pop() - } - - return q - }) + setMessages(prev => trimLastExchange(prev)) + setHistoryItems(prev => trimLastExchange(prev)) sys(`undid ${r.removed} messages`) } else { sys('nothing to undo') @@ -1911,18 +2095,25 @@ export function App({ gw }: { gw: GatewayClient }) { } if (sid) { - gw.request('session.undo', { session_id: sid }).catch(() => {}) + rpc('session.undo', { session_id: sid }).then((r: any) => { + if (!r) { + return + } + + if (r.removed <= 0) { + sys('nothing to retry') + + return + } + + setMessages(prev => trimLastExchange(prev)) + setHistoryItems(prev => trimLastExchange(prev)) + send(lastUserMsg) + }) + + return true } - setMessages(prev => { - const q = [...prev] - - while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') { - q.pop() - } - - return q - }) send(lastUserMsg) return true @@ -1966,37 +2157,28 @@ export function App({ gw }: { gw: GatewayClient }) { return true case 'model': + if (guardBusySessionSwitch('change models')) { + return true + } + if (!arg) { - rpc('config.get', { key: 'provider' }).then((r: any) => { + setModelPicker(true) + } else { + rpc('config.set', { session_id: sid, key: 'model', value: arg.trim() }).then((r: any) => { if (!r) { return } - panel('Model', [ - { - rows: [ - ['Model', r.model], - ['Provider', r.provider] - ] - } - ]) - }) - } else { - rpc('config.set', { session_id: sid, key: 'model', value: arg.replace('--global', '').trim() }).then( - (r: any) => { - if (!r?.value) { - return - } + if (!r.value) { + sys('error: invalid response: model switch') - sys(`model → ${r.value}`) - - if (r.warning) { - sys(`warning: ${r.warning}`) - } - - setInfo(prev => (prev ? { ...prev, model: r.value } : prev)) + return } - ) + + sys(`model → ${r.value}`) + maybeWarn(r) + setInfo(prev => (prev ? { ...prev, model: r.value } : { model: r.value, skills: {}, tools: {} })) + }) } return true @@ -2019,7 +2201,12 @@ export function App({ gw }: { gw: GatewayClient }) { case 'provider': gw.request('slash.exec', { command: 'provider', session_id: sid }) - .then((r: any) => page(r?.output || '(no output)', 'Provider')) + .then((r: any) => { + page( + r?.warning ? `warning: ${r.warning}\n\n${r?.output || '(no output)'}` : r?.output || '(no output)', + 'Provider' + ) + }) .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) return true @@ -2057,13 +2244,23 @@ export function App({ gw }: { gw: GatewayClient }) { return true case 'reasoning': - rpc('config.set', { session_id: sid, key: 'reasoning', value: arg || 'medium' }).then((r: any) => { - if (!r?.value) { - return - } + if (!arg) { + rpc('config.get', { key: 'reasoning' }).then((r: any) => { + if (!r?.value) { + return + } - sys(`reasoning: ${r.value}`) - }) + sys(`reasoning: ${r.value} · display ${r.display || 'hide'}`) + }) + } else { + rpc('config.set', { session_id: sid, key: 'reasoning', value: arg }).then((r: any) => { + if (!r?.value) { + return + } + + sys(`reasoning: ${r.value}`) + }) + } return true @@ -2080,28 +2277,61 @@ export function App({ gw }: { gw: GatewayClient }) { case 'personality': if (arg) { - rpc('config.set', { key: 'personality', value: arg }).then((r: any) => { + rpc('config.set', { session_id: sid, key: 'personality', value: arg }).then((r: any) => { if (!r) { return } - sys(`personality: ${r.value || 'default'}`) + if (r.history_reset) { + resetVisibleHistory(r.info ?? null) + } + + sys(`personality: ${r.value || 'default'}${r.history_reset ? ' · transcript cleared' : ''}`) + maybeWarn(r) }) } else { gw.request('slash.exec', { command: 'personality', session_id: sid }) - .then((r: any) => panel('Personality', [{ text: r?.output || '(no output)' }])) + .then((r: any) => { + panel('Personality', [ + { + text: r?.warning + ? `warning: ${r.warning}\n\n${r?.output || '(no output)'}` + : r?.output || '(no output)' + } + ]) + }) .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) } return true case 'compress': - rpc('session.compress', { session_id: sid }).then((r: any) => { + rpc('session.compress', { session_id: sid, ...(arg ? { focus_topic: arg } : {}) }).then((r: any) => { if (!r) { return } - sys(`compressed${r.usage?.total ? ' · ' + fmtK(r.usage.total) + ' tok' : ''}`) + if (Array.isArray(r.messages)) { + const resumed = toTranscriptMessages(r.messages) + setMessages(resumed) + setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed) + } + + if (r.info) { + setInfo(r.info) + } + + if (r.usage) { + setUsage(prev => ({ ...prev, ...r.usage })) + } + + if ((r.removed ?? 0) <= 0) { + sys('nothing to compress') + + return + } + + sys(`compressed ${r.removed} messages${r.usage?.total ? ' · ' + fmtK(r.usage.total) + ' tok' : ''}`) }) return true @@ -2112,7 +2342,7 @@ export function App({ gw }: { gw: GatewayClient }) { return } - sys(`killed ${r.killed ?? 0} process(es)`) + sys(`killed ${r.killed ?? 0} registered process(es)`) }) return true @@ -2120,15 +2350,19 @@ export function App({ gw }: { gw: GatewayClient }) { case 'branch': case 'fork': - rpc('session.branch', { session_id: sid, name: arg }).then((r: any) => { - if (r?.session_id) { - setSid(r.session_id) - setSessionStartedAt(Date.now()) - setHistoryItems([]) - setMessages([]) - sys(`branched → ${r.title}`) - } - }) + { + const prevSid = sid + rpc('session.branch', { session_id: sid, name: arg }).then((r: any) => { + if (r?.session_id) { + void closeSession(prevSid) + setSid(r.session_id) + setSessionStartedAt(Date.now()) + setHistoryItems([]) + setMessages([]) + sys(`branched → ${r.title}`) + } + }) + } return true @@ -2249,7 +2483,7 @@ export function App({ gw }: { gw: GatewayClient }) { } setVoiceEnabled(!!r?.enabled) - sys(`voice${arg === 'on' || arg === 'off' ? '' : ':'} ${r.enabled ? 'on' : 'off'}`) + sys(`voice: ${r.enabled ? 'on' : 'off'}`) }) return true @@ -2411,7 +2645,13 @@ export function App({ gw }: { gw: GatewayClient }) { } gw.request('slash.exec', { command: cmd.slice(1), session_id: sid }) - .then((r: any) => sys(r?.output || '/skills: no output')) + .then((r: any) => { + sys( + r?.warning + ? `warning: ${r.warning}\n${r?.output || '/skills: no output'}` + : r?.output || '/skills: no output' + ) + }) .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) return true @@ -2481,7 +2721,9 @@ export function App({ gw }: { gw: GatewayClient }) { .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) } else { gw.request('slash.exec', { command: cmd.slice(1), session_id: sid }) - .then((r: any) => sys(r?.output || '(no output)')) + .then((r: any) => { + sys(r?.warning ? `warning: ${r.warning}\n${r?.output || '(no output)'}` : r?.output || '(no output)') + }) .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) } @@ -2558,14 +2800,20 @@ export function App({ gw }: { gw: GatewayClient }) { default: gw.request('slash.exec', { command: cmd.slice(1), session_id: sid }) - .then((r: any) => sys(r?.output || `/${name}: no output`)) - .catch((e: unknown) => { + .then((r: any) => { + sys( + r?.warning + ? `warning: ${r.warning}\n${r?.output || `/${name}: no output`}` + : r?.output || `/${name}: no output` + ) + }) + .catch(() => { gw.request('command.dispatch', { name: name ?? '', arg, session_id: sid }) .then((raw: any) => { const d = asRpcResult(raw) if (!d?.type) { - sys(`error: ${rpcErrorMessage(e)}`) + sys('error: invalid response: command.dispatch') return } @@ -2586,7 +2834,7 @@ export function App({ gw }: { gw: GatewayClient }) { } } }) - .catch(() => sys(`error: ${rpcErrorMessage(e)}`)) + .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) }) return true @@ -2595,8 +2843,10 @@ export function App({ gw }: { gw: GatewayClient }) { [ catalog, compact, + guardBusySessionSwitch, gw, lastUserMsg, + maybeWarn, messages, newSession, page, @@ -2604,6 +2854,7 @@ export function App({ gw }: { gw: GatewayClient }) { pastes, pushActivity, rpc, + resetVisibleHistory, send, sid, statusBar, @@ -2755,10 +3006,15 @@ export function App({ gw }: { gw: GatewayClient }) { { - gw.request('approval.respond', { choice, session_id: sid }).catch(() => {}) - setApproval(null) - sys(choice === 'deny' ? 'denied' : `approved (${choice})`) - setStatus('running…') + rpc('approval.respond', { choice, session_id: sid }).then(r => { + if (!r) { + return + } + + setApproval(null) + sys(choice === 'deny' ? 'denied' : `approved (${choice})`) + setStatus('running…') + }) }} req={approval} t={theme} @@ -2773,9 +3029,14 @@ export function App({ gw }: { gw: GatewayClient }) { icon="🔐" label="sudo password required" onSubmit={pw => { - gw.request('sudo.respond', { request_id: sudo.requestId, password: pw }).catch(() => {}) - setSudo(null) - setStatus('running…') + rpc('sudo.respond', { request_id: sudo.requestId, password: pw }).then(r => { + if (!r) { + return + } + + setSudo(null) + setStatus('running…') + }) }} t={theme} /> @@ -2789,9 +3050,14 @@ export function App({ gw }: { gw: GatewayClient }) { icon="🔑" label={secret.prompt} onSubmit={val => { - gw.request('secret.respond', { request_id: secret.requestId, value: val }).catch(() => {}) - setSecret(null) - setStatus('running…') + rpc('secret.respond', { request_id: secret.requestId, value: val }).then(r => { + if (!r) { + return + } + + setSecret(null) + setStatus('running…') + }) }} sub={`for ${secret.envVar}`} t={theme} @@ -2805,11 +3071,28 @@ export function App({ gw }: { gw: GatewayClient }) { )} + {modelPicker && ( + + setModelPicker(false)} + onSelect={value => { + setModelPicker(false) + slash(`/model ${value}`) + }} + sessionId={sid} + t={theme} + /> + + )} + + + {bgTasks.size > 0 && ( - {bgTasks.size} background {bgTasks.size === 1 ? 'task' : 'tasks'} running · /stop to cancel + {bgTasks.size} background {bgTasks.size === 1 ? 'task' : 'tasks'} running )} diff --git a/ui-tui/src/components/modelPicker.tsx b/ui-tui/src/components/modelPicker.tsx new file mode 100644 index 000000000..54e6733f8 --- /dev/null +++ b/ui-tui/src/components/modelPicker.tsx @@ -0,0 +1,241 @@ +import { Box, Text, useInput } from '@hermes/ink' +import { useEffect, useState } from 'react' + +import type { GatewayClient } from '../gatewayClient.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)) + +export function ModelPicker({ + gw, + onCancel, + onSelect, + sessionId, + t +}: { + gw: GatewayClient + onCancel: () => void + onSelect: (value: string) => void + sessionId: string | null + t: Theme +}) { + const [providers, setProviders] = useState([]) + const [currentModel, setCurrentModel] = useState('') + const [err, setErr] = useState('') + const [loading, setLoading] = useState(true) + const [persistGlobal, setPersistGlobal] = useState(false) + const [providerIdx, setProviderIdx] = useState(0) + const [modelIdx, setModelIdx] = useState(0) + const [stage, setStage] = useState<'model' | 'provider'>('provider') + + useEffect(() => { + gw.request('model.options', sessionId ? { session_id: sessionId } : {}) + .then((raw: any) => { + const r = asRpcResult(raw) + + if (!r) { + setErr('invalid response: model.options') + setLoading(false) + + return + } + + const next = (r.providers ?? []) as ProviderItem[] + setProviders(next) + setCurrentModel(String(r.model ?? '')) + setProviderIdx( + Math.max( + 0, + next.findIndex(p => p.is_current) + ) + ) + setModelIdx(0) + setErr('') + setLoading(false) + }) + .catch((e: unknown) => { + setErr(rpcErrorMessage(e)) + setLoading(false) + }) + }, [gw, sessionId]) + + const provider = providers[providerIdx] + const models = provider?.models ?? [] + + const visibleItems = (items: string[], sel: number) => { + const off = pageOffset(items.length, sel) + + return { items: items.slice(off, off + VISIBLE), off } + } + + useInput((ch, key) => { + if (key.escape) { + if (stage === 'model') { + setStage('provider') + setModelIdx(0) + + return + } + + onCancel() + + return + } + + const count = stage === 'provider' ? providers.length : models.length + const sel = stage === 'provider' ? providerIdx : modelIdx + const setSel = stage === 'provider' ? setProviderIdx : setModelIdx + + if (key.upArrow && sel > 0) { + setSel(v => v - 1) + + return + } + + if (key.downArrow && sel < count - 1) { + setSel(v => v + 1) + + return + } + + if (key.return) { + if (stage === 'provider') { + if (!provider) { + return + } + + setStage('model') + setModelIdx(0) + + return + } + + const model = models[modelIdx] + + if (provider && model) { + onSelect(`${model} --provider ${provider.slug}${persistGlobal ? ' --global' : ''}`) + } else { + setStage('provider') + } + + return + } + + if (ch.toLowerCase() === 'g') { + setPersistGlobal(v => !v) + + return + } + + const n = ch === '0' ? 10 : parseInt(ch, 10) + + if (!Number.isNaN(n) && n >= 1 && n <= Math.min(10, count)) { + const off = pageOffset(count, sel) + + if (stage === 'provider') { + const next = off + n - 1 + + if (providers[next]) { + setProviderIdx(next) + } + } else if (provider && models[off + n - 1]) { + onSelect(`${models[off + n - 1]} --provider ${provider.slug}${persistGlobal ? ' --global' : ''}`) + } + } + }) + + if (loading) { + return loading models… + } + + if (err) { + return ( + + error: {err} + Esc to cancel + + ) + } + + if (!providers.length) { + return ( + + no authenticated providers + Esc to cancel + + ) + } + + if (stage === 'provider') { + const rows = providers.map( + p => `${p.is_current ? '*' : ' '} ${p.name} · ${p.total_models ?? p.models?.length ?? 0} models` + ) + + const { items, off } = visibleItems(rows, providerIdx) + + return ( + + + Select Provider + + Current model: {currentModel || '(unknown)'} + {provider?.warning ? warning: {provider.warning} : null} + {off > 0 && ↑ {off} more} + {items.map((row, i) => { + const idx = off + i + + return ( + + {providerIdx === idx ? '▸ ' : ' '} + {i + 1}. {row} + + ) + })} + {off + VISIBLE < rows.length && ↓ {rows.length - off - VISIBLE} more} + persist: {persistGlobal ? 'global' : 'session'} · g toggle + ↑/↓ select · Enter choose · 1-9,0 quick · Esc cancel + + ) + } + + const { items, off } = visibleItems(models, modelIdx) + + return ( + + + Select Model + + {provider?.name || '(unknown provider)'} + {!models.length ? no models listed for this provider : null} + {provider?.warning ? warning: {provider.warning} : null} + {off > 0 && ↑ {off} more} + {items.map((row, i) => { + const idx = off + i + + return ( + + {modelIdx === idx ? '▸ ' : ' '} + {i + 1}. {row} + + ) + })} + {off + VISIBLE < models.length && ↓ {models.length - off - VISIBLE} more} + persist: {persistGlobal ? 'global' : 'session'} · g toggle + + {models.length ? '↑/↓ select · Enter switch · 1-9,0 quick · Esc back' : 'Enter/Esc back'} + + + ) +} diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 385dd5f48..edc586e93 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -30,10 +30,68 @@ const dim = (s: string) => DIM + s + DIM_OFF let _seg: Intl.Segmenter | null = null const seg = () => (_seg ??= new Intl.Segmenter(undefined, { granularity: 'grapheme' })) +function graphemeStops(s: string) { + const stops = [0] + + for (const { index } of seg().segment(s)) { + if (index > 0) { + stops.push(index) + } + } + + if (stops.at(-1) !== s.length) { + stops.push(s.length) + } + + return stops +} + +function snapPos(s: string, p: number) { + const pos = Math.max(0, Math.min(p, s.length)) + let last = 0 + + for (const stop of graphemeStops(s)) { + if (stop > pos) { + break + } + + last = stop + } + + return last +} + +function prevPos(s: string, p: number) { + const pos = snapPos(s, p) + let prev = 0 + + for (const stop of graphemeStops(s)) { + if (stop >= pos) { + return prev + } + + prev = stop + } + + return prev +} + +function nextPos(s: string, p: number) { + const pos = snapPos(s, p) + + for (const stop of graphemeStops(s)) { + if (stop > pos) { + return stop + } + } + + return s.length +} + // ── Word movement ──────────────────────────────────────────────────── function wordLeft(s: string, p: number) { - let i = p - 1 + let i = snapPos(s, p) - 1 while (i > 0 && /\s/.test(s[i]!)) { i-- @@ -47,7 +105,7 @@ function wordLeft(s: string, p: number) { } function wordRight(s: string, p: number) { - let i = p + let i = snapPos(s, p) while (i < s.length && !/\s/.test(s[i]!)) { i++ @@ -252,7 +310,7 @@ export function TextInput({ const commit = (next: string, nextCur: number, track = true) => { const prev = vRef.current - const c = Math.max(0, Math.min(nextCur, next.length)) + const c = snapPos(next, nextCur) if (track && next !== prev) { undo.current.push({ cursor: curRef.current, value: prev }) @@ -316,11 +374,10 @@ export function TextInput({ useInput( (inp: string, k: Key, event: InputEvent) => { - // Some terminals normalize Ctrl+V to "v"; others deliver raw ^V (\x16). - const ctrlPaste = k.ctrl && (inp.toLowerCase() === 'v' || event.keypress.raw === '\x16') - const metaPaste = k.meta && inp.toLowerCase() === 'v' + const raw = event.keypress.raw + const metaPaste = raw === '\x1bv' || raw === '\x1bV' - if (ctrlPaste || metaPaste) { + if (metaPaste) { return void emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current }) } @@ -366,9 +423,9 @@ export function TextInput({ } else if (k.end || (k.ctrl && inp === 'e')) { c = v.length } else if (k.leftArrow) { - c = mod ? wordLeft(v, c) : Math.max(0, c - 1) + c = mod ? wordLeft(v, c) : prevPos(v, c) } else if (k.rightArrow) { - c = mod ? wordRight(v, c) : Math.min(v.length, c + 1) + c = mod ? wordRight(v, c) : nextPos(v, c) } else if (k.meta && inp === 'b') { c = wordLeft(v, c) } else if (k.meta && inp === 'f') { @@ -382,15 +439,16 @@ export function TextInput({ v = v.slice(0, t) + v.slice(c) c = t } else { - v = v.slice(0, c - 1) + v.slice(c) - c-- + const t = prevPos(v, c) + v = v.slice(0, t) + v.slice(c) + c = t } } else if (k.delete && fwdDel.current && c < v.length) { if (mod) { const t = wordRight(v, c) v = v.slice(0, c) + v.slice(t) } else { - v = v.slice(0, c) + v.slice(c + 1) + v = v.slice(0, c) + v.slice(nextPos(v, c)) } } else if (k.ctrl && inp === 'w' && c > 0) { const t = wordLeft(v, c) diff --git a/ui-tui/src/constants.ts b/ui-tui/src/constants.ts index 9f1c48771..9e7cac999 100644 --- a/ui-tui/src/constants.ts +++ b/ui-tui/src/constants.ts @@ -25,7 +25,7 @@ export const HOTKEYS: [string, string][] = [ ['Ctrl+G', 'open $EDITOR for prompt'], ['Ctrl+L', 'new session (clear)'], ['Ctrl+T', 'cycle thinking detail'], - ['Ctrl+V / Alt+V', 'paste clipboard image'], + ['Alt+V / /paste', 'paste clipboard image'], ['Tab', 'apply completion'], ['↑/↓', 'completions / queue edit / history'], ['Ctrl+A/E', 'home / end of line'], diff --git a/ui-tui/src/gatewayClient.ts b/ui-tui/src/gatewayClient.ts index 40bd77763..2c98c64e0 100644 --- a/ui-tui/src/gatewayClient.ts +++ b/ui-tui/src/gatewayClient.ts @@ -5,6 +5,8 @@ import { createInterface } from 'node:readline' 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) +const REQUEST_TIMEOUT_MS = Math.max(30000, parseInt(process.env.HERMES_TUI_RPC_TIMEOUT_MS ?? '120000', 10) || 120000) export interface GatewayEvent { type: string @@ -23,27 +25,78 @@ export class GatewayClient extends EventEmitter { private logs: string[] = [] private pending = new Map() private bufferedEvents: GatewayEvent[] = [] + private pendingExit: number | null | undefined + private ready = false + private readyTimer: ReturnType | null = null private subscribed = false + private stdoutRl: ReturnType | null = null + private stderrRl: ReturnType | null = null + + private publish(ev: GatewayEvent) { + if (ev.type === 'gateway.ready') { + this.ready = true + + if (this.readyTimer) { + clearTimeout(this.readyTimer) + this.readyTimer = null + } + } + + if (this.subscribed) { + this.emit('event', ev) + + return + } + + this.bufferedEvents.push(ev) + } start() { const root = process.env.HERMES_PYTHON_SRC_ROOT ?? resolve(import.meta.dirname, '../../') + const python = process.env.HERMES_PYTHON ?? resolve(root, 'venv/bin/python') + const cwd = process.env.HERMES_CWD || root + this.ready = false + this.pendingExit = undefined + this.stdoutRl?.close() + this.stderrRl?.close() + this.stdoutRl = null + this.stderrRl = null - this.proc = spawn(process.env.HERMES_PYTHON ?? resolve(root, 'venv/bin/python'), ['-m', 'tui_gateway.entry'], { - cwd: process.env.HERMES_CWD || root, + if (this.proc && !this.proc.killed && this.proc.exitCode === null) { + this.proc.kill() + } + + if (this.readyTimer) { + clearTimeout(this.readyTimer) + } + + this.readyTimer = setTimeout(() => { + if (this.ready) { + return + } + + this.pushLog(`[startup] timed out waiting for gateway.ready (python=${python}, cwd=${cwd})`) + this.publish({ type: 'gateway.start_timeout', payload: { cwd, python } }) + }, STARTUP_TIMEOUT_MS) + + this.proc = spawn(python, ['-m', 'tui_gateway.entry'], { + cwd, stdio: ['pipe', 'pipe', 'pipe'] }) - createInterface({ input: this.proc.stdout! }).on('line', raw => { + this.stdoutRl = createInterface({ input: this.proc.stdout! }) + this.stdoutRl.on('line', raw => { try { this.dispatch(JSON.parse(raw)) } catch { const preview = raw.trim().slice(0, MAX_LOG_PREVIEW) || '(empty line)' this.pushLog(`[protocol] malformed stdout: ${preview}`) - this.emit('event', { type: 'gateway.protocol_error', payload: { preview } } satisfies GatewayEvent) + this.publish({ type: 'gateway.protocol_error', payload: { preview } } satisfies GatewayEvent) } }) - createInterface({ input: this.proc.stderr! }).on('line', raw => { + this.stderrRl = createInterface({ input: this.proc.stderr! }) + this.stderrRl.on('line', raw => { const line = raw.trim() if (!line) { @@ -51,18 +104,28 @@ export class GatewayClient extends EventEmitter { } this.pushLog(line) - this.emit('event', { type: 'gateway.stderr', payload: { line } } satisfies GatewayEvent) + this.publish({ type: 'gateway.stderr', payload: { line } } satisfies GatewayEvent) }) this.proc.on('error', err => { this.pushLog(`[spawn] ${err.message}`) this.rejectPending(new Error(`gateway error: ${err.message}`)) - this.emit('event', { type: 'gateway.stderr', payload: { line: `[spawn] ${err.message}` } } satisfies GatewayEvent) + this.publish({ type: 'gateway.stderr', payload: { line: `[spawn] ${err.message}` } } satisfies GatewayEvent) }) this.proc.on('exit', code => { + if (this.readyTimer) { + clearTimeout(this.readyTimer) + this.readyTimer = null + } + this.rejectPending(new Error(`gateway exited${code === null ? '' : ` (${code})`}`)) - this.emit('exit', code) + + if (this.subscribed) { + this.emit('exit', code) + } else { + this.pendingExit = code + } }) } @@ -78,13 +141,7 @@ export class GatewayClient extends EventEmitter { } if (msg.method === 'event') { - const ev = msg.params as GatewayEvent - - if (this.subscribed) { - this.emit('event', ev) - } else { - this.bufferedEvents.push(ev) - } + this.publish(msg.params as GatewayEvent) } } @@ -110,6 +167,12 @@ export class GatewayClient extends EventEmitter { for (const ev of pending) { this.emit('event', ev) } + + if (this.pendingExit !== undefined) { + const code = this.pendingExit + this.pendingExit = undefined + this.emit('exit', code) + } } getLogTail(limit = 20): string { @@ -117,6 +180,10 @@ export class GatewayClient extends EventEmitter { } request(method: string, params: Record = {}): Promise { + if (!this.proc?.stdin || this.proc.killed || this.proc.exitCode !== null) { + this.start() + } + if (!this.proc?.stdin) { return Promise.reject(new Error('gateway not running')) } @@ -128,7 +195,7 @@ export class GatewayClient extends EventEmitter { if (this.pending.delete(id)) { reject(new Error(`timeout: ${method}`)) } - }, 30_000) + }, REQUEST_TIMEOUT_MS) this.pending.set(id, { reject: e => { diff --git a/ui-tui/src/hooks/useCompletion.ts b/ui-tui/src/hooks/useCompletion.ts index 50054e90d..1c74872c1 100644 --- a/ui-tui/src/hooks/useCompletion.ts +++ b/ui-tui/src/hooks/useCompletion.ts @@ -2,7 +2,7 @@ import { startTransition, useEffect, useRef, useState } from 'react' import type { GatewayClient } from '../gatewayClient.js' -const TAB_PATH_RE = /((?:\.\.?\/|~\/|\/|@)[^\s]*)$/ +const TAB_PATH_RE = /((?:["']?(?:[A-Za-z]:[\\/]|\.{1,2}\/|~\/|\/|@|[^"'`\s]+\/))[^\s]*)$/ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient) { const [completions, setCompletions] = useState<{ text: string; display: string; meta: string }[]>([]) @@ -59,7 +59,18 @@ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient setCompReplace(isSlash ? (r?.replace_from ?? 1) : input.length - (pathWord?.length ?? 0)) }) }) - .catch(() => {}) + .catch((e: unknown) => { + if (ref.current !== input) { + return + } + + const meta = e instanceof Error && e.message ? e.message : 'unavailable' + startTransition(() => { + setCompletions([{ text: '', display: 'completion unavailable', meta }]) + setCompIdx(0) + setCompReplace(isSlash ? 1 : input.length - (pathWord?.length ?? 0)) + }) + }) }, 60) return () => clearTimeout(t) diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index 1841bdd77..9c67b1e37 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -243,4 +243,4 @@ export const userDisplay = (text: string): string => { } export const isPasteBackedText = (text: string): boolean => - /\[\[paste:\d+(?:[^\n]*?)\]\]|\[paste #\d+ (?:attached|excerpt)\]/.test(text) + /\[\[paste:\d+(?:[^\n]*?)\]\]|\[paste #\d+ (?:attached|excerpt)(?:[^\n]*?)\]/.test(text) diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 784b69015..e8d94e64c 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -43,6 +43,7 @@ export interface SessionInfo { tools: Record update_behind?: number | null update_command?: string + usage?: Usage version?: string }