fix(ui-tui): harden TUI error handling, model validation, command UX parity, and gateway lifecycle

This commit is contained in:
Brooklyn Nicholson 2026-04-13 18:29:24 -05:00
parent 783c6b6ed6
commit aeb53131f3
15 changed files with 1303 additions and 309 deletions

6
cli.py
View file

@ -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

View file

@ -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="<path>"),

View file

@ -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}`. "

View file

@ -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"]

View file

@ -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: {

View file

@ -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")

1
ui-tui/.gitignore vendored
View file

@ -1,3 +1,4 @@
dist/
node_modules/
src/*.js
docs/

File diff suppressed because it is too large Load diff

View file

@ -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<ProviderItem[]>([])
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 <Text color={t.color.dim}>loading models</Text>
}
if (err) {
return (
<Box flexDirection="column">
<Text color={t.color.label}>error: {err}</Text>
<Text color={t.color.dim}>Esc to cancel</Text>
</Box>
)
}
if (!providers.length) {
return (
<Box flexDirection="column">
<Text color={t.color.dim}>no authenticated providers</Text>
<Text color={t.color.dim}>Esc to cancel</Text>
</Box>
)
}
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 (
<Box flexDirection="column">
<Text bold color={t.color.amber}>
Select Provider
</Text>
<Text color={t.color.dim}>Current model: {currentModel || '(unknown)'}</Text>
{provider?.warning ? <Text color={t.color.label}>warning: {provider.warning}</Text> : null}
{off > 0 && <Text color={t.color.dim}> {off} more</Text>}
{items.map((row, i) => {
const idx = off + i
return (
<Text color={providerIdx === idx ? t.color.cornsilk : t.color.dim} key={row}>
{providerIdx === idx ? '▸ ' : ' '}
{i + 1}. {row}
</Text>
)
})}
{off + VISIBLE < rows.length && <Text color={t.color.dim}> {rows.length - off - VISIBLE} more</Text>}
<Text color={t.color.dim}>persist: {persistGlobal ? 'global' : 'session'} · g toggle</Text>
<Text color={t.color.dim}>/ select · Enter choose · 1-9,0 quick · Esc cancel</Text>
</Box>
)
}
const { items, off } = visibleItems(models, modelIdx)
return (
<Box flexDirection="column">
<Text bold color={t.color.amber}>
Select Model
</Text>
<Text color={t.color.dim}>{provider?.name || '(unknown provider)'}</Text>
{!models.length ? <Text color={t.color.dim}>no models listed for this provider</Text> : null}
{provider?.warning ? <Text color={t.color.label}>warning: {provider.warning}</Text> : null}
{off > 0 && <Text color={t.color.dim}> {off} more</Text>}
{items.map((row, i) => {
const idx = off + i
return (
<Text color={modelIdx === idx ? t.color.cornsilk : t.color.dim} key={row}>
{modelIdx === idx ? '▸ ' : ' '}
{i + 1}. {row}
</Text>
)
})}
{off + VISIBLE < models.length && <Text color={t.color.dim}> {models.length - off - VISIBLE} more</Text>}
<Text color={t.color.dim}>persist: {persistGlobal ? 'global' : 'session'} · g toggle</Text>
<Text color={t.color.dim}>
{models.length ? '↑/↓ select · Enter switch · 1-9,0 quick · Esc back' : 'Enter/Esc back'}
</Text>
</Box>
)
}

View file

@ -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)

View file

@ -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'],

View file

@ -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<string, Pending>()
private bufferedEvents: GatewayEvent[] = []
private pendingExit: number | null | undefined
private ready = false
private readyTimer: ReturnType<typeof setTimeout> | null = null
private subscribed = false
private stdoutRl: ReturnType<typeof createInterface> | null = null
private stderrRl: ReturnType<typeof createInterface> | 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<string, unknown> = {}): Promise<unknown> {
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 => {

View file

@ -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)

View file

@ -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)

View file

@ -43,6 +43,7 @@ export interface SessionInfo {
tools: Record<string, string[]>
update_behind?: number | null
update_command?: string
usage?: Usage
version?: string
}