mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
fix(ui-tui): harden TUI error handling, model validation, command UX parity, and gateway lifecycle
This commit is contained in:
parent
783c6b6ed6
commit
aeb53131f3
15 changed files with 1303 additions and 309 deletions
6
cli.py
6
cli.py
|
|
@ -1194,6 +1194,10 @@ def _resolve_attachment_path(raw_path: str) -> Path | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
expanded = os.path.expandvars(os.path.expanduser(token))
|
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)
|
path = Path(expanded)
|
||||||
if not path.is_absolute():
|
if not path.is_absolute():
|
||||||
base_dir = Path(os.getenv("TERMINAL_CWD", os.getcwd()))
|
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 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 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:
|
if not starts_like_path:
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||||
args_hint="[focus topic]"),
|
args_hint="[focus topic]"),
|
||||||
CommandDef("rollback", "List or restore filesystem checkpoints", "Session",
|
CommandDef("rollback", "List or restore filesystem checkpoints", "Session",
|
||||||
args_hint="[number]"),
|
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",
|
CommandDef("approve", "Approve a pending dangerous command", "Session",
|
||||||
gateway_only=True, args_hint="[session|always]"),
|
gateway_only=True, args_hint="[session|always]"),
|
||||||
CommandDef("deny", "Deny a pending dangerous command", "Session",
|
CommandDef("deny", "Deny a pending dangerous command", "Session",
|
||||||
|
|
@ -96,7 +96,7 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||||
# Configuration
|
# Configuration
|
||||||
CommandDef("config", "Show current configuration", "Configuration",
|
CommandDef("config", "Show current configuration", "Configuration",
|
||||||
cli_only=True),
|
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",
|
CommandDef("provider", "Show available providers and current provider",
|
||||||
"Configuration"),
|
"Configuration"),
|
||||||
|
|
||||||
|
|
@ -152,7 +152,7 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||||
cli_only=True, aliases=("gateway",)),
|
cli_only=True, aliases=("gateway",)),
|
||||||
CommandDef("copy", "Copy the last assistant response to clipboard", "Info",
|
CommandDef("copy", "Copy the last assistant response to clipboard", "Info",
|
||||||
cli_only=True, args_hint="[number]"),
|
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),
|
cli_only=True),
|
||||||
CommandDef("image", "Attach a local image file for your next prompt", "Info",
|
CommandDef("image", "Attach a local image file for your next prompt", "Info",
|
||||||
cli_only=True, args_hint="<path>"),
|
cli_only=True, args_hint="<path>"),
|
||||||
|
|
|
||||||
|
|
@ -1803,8 +1803,8 @@ def validate_requested_model(
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"accepted": True,
|
"accepted": False,
|
||||||
"persist": True,
|
"persist": False,
|
||||||
"recognized": False,
|
"recognized": False,
|
||||||
"message": message,
|
"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')}`"
|
message += f"\n If this server expects `/v1`, try base URL: `{probe.get('suggested_base_url')}`"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"accepted": True,
|
"accepted": False,
|
||||||
"persist": True,
|
"persist": False,
|
||||||
"recognized": False,
|
"recognized": False,
|
||||||
"message": message,
|
"message": message,
|
||||||
}
|
}
|
||||||
|
|
@ -1882,8 +1882,8 @@ def validate_requested_model(
|
||||||
# but warn so typos don't silently break things.
|
# but warn so typos don't silently break things.
|
||||||
provider_label = _PROVIDER_LABELS.get(normalized, normalized)
|
provider_label = _PROVIDER_LABELS.get(normalized, normalized)
|
||||||
return {
|
return {
|
||||||
"accepted": True,
|
"accepted": False,
|
||||||
"persist": True,
|
"persist": False,
|
||||||
"recognized": False,
|
"recognized": False,
|
||||||
"message": (
|
"message": (
|
||||||
f"Could not reach the {provider_label} API to validate `{requested}`. "
|
f"Could not reach the {provider_label} API to validate `{requested}`. "
|
||||||
|
|
|
||||||
|
|
@ -437,33 +437,33 @@ class TestValidateApiNotFound:
|
||||||
def test_warning_includes_suggestions(self):
|
def test_warning_includes_suggestions(self):
|
||||||
result = _validate("anthropic/claude-opus-4.5")
|
result = _validate("anthropic/claude-opus-4.5")
|
||||||
assert result["accepted"] is False
|
assert result["accepted"] is False
|
||||||
|
assert result["persist"] is False
|
||||||
assert "Similar models" in result["message"]
|
assert "Similar models" in result["message"]
|
||||||
|
|
||||||
|
|
||||||
# -- validate — API unreachable — accept and persist everything ----------------
|
# -- validate — API unreachable — reject with guidance ----------------
|
||||||
|
|
||||||
class TestValidateApiFallback:
|
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)
|
result = _validate("anthropic/claude-opus-4.6", api_models=None)
|
||||||
assert result["accepted"] is True
|
assert result["accepted"] is False
|
||||||
assert result["persist"] is True
|
assert result["persist"] is False
|
||||||
|
|
||||||
def test_unknown_model_also_accepted_when_api_down(self):
|
def test_unknown_model_also_rejected_when_api_down(self):
|
||||||
"""No hardcoded catalog gatekeeping — accept, persist, and warn."""
|
|
||||||
result = _validate("anthropic/claude-next-gen", api_models=None)
|
result = _validate("anthropic/claude-next-gen", api_models=None)
|
||||||
assert result["accepted"] is True
|
assert result["accepted"] is False
|
||||||
assert result["persist"] is True
|
assert result["persist"] is False
|
||||||
assert "could not reach" in result["message"].lower()
|
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)
|
result = _validate("glm-5", provider="zai", api_models=None)
|
||||||
assert result["accepted"] is True
|
assert result["accepted"] is False
|
||||||
assert result["persist"] is True
|
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)
|
result = _validate("some-model", provider="totally-unknown", api_models=None)
|
||||||
assert result["accepted"] is True
|
assert result["accepted"] is False
|
||||||
assert result["persist"] is True
|
assert result["persist"] is False
|
||||||
|
|
||||||
def test_custom_endpoint_warns_with_probed_url_and_v1_hint(self):
|
def test_custom_endpoint_warns_with_probed_url_and_v1_hint(self):
|
||||||
with patch(
|
with patch(
|
||||||
|
|
@ -483,7 +483,7 @@ class TestValidateApiFallback:
|
||||||
base_url="http://localhost:8000",
|
base_url="http://localhost:8000",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result["accepted"] is True
|
assert result["accepted"] is False
|
||||||
assert result["persist"] is True
|
assert result["persist"] is False
|
||||||
assert "http://localhost:8000/v1/models" in result["message"]
|
assert "http://localhost:8000/v1/models" in result["message"]
|
||||||
assert "http://localhost:8000/v1" in result["message"]
|
assert "http://localhost:8000/v1" in result["message"]
|
||||||
|
|
|
||||||
|
|
@ -167,11 +167,87 @@ def test_config_set_model_uses_live_switch_path(monkeypatch):
|
||||||
assert seen["args"] == ("sid", "session-key", "new/model")
|
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):
|
def test_session_compress_uses_compress_helper(monkeypatch):
|
||||||
agent = types.SimpleNamespace()
|
agent = types.SimpleNamespace()
|
||||||
server._sessions["sid"] = _session(agent=agent)
|
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"})
|
monkeypatch.setattr(server, "_session_info", lambda _agent: {"model": "x"})
|
||||||
|
|
||||||
with patch("tui_gateway.server._emit") as emit:
|
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
|
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):
|
def test_input_detect_drop_attaches_image(monkeypatch):
|
||||||
fake_cli = types.ModuleType("cli")
|
fake_cli = types.ModuleType("cli")
|
||||||
fake_cli._detect_file_drop = lambda raw: {
|
fake_cli._detect_file_drop = lambda raw: {
|
||||||
|
|
|
||||||
|
|
@ -183,10 +183,19 @@ def handle_request(req: dict) -> dict | None:
|
||||||
|
|
||||||
|
|
||||||
def _sess(params, rid):
|
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"))
|
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 ────────────────────────────────────────────────────────
|
# ── Config I/O ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _load_cfg() -> dict:
|
def _load_cfg() -> dict:
|
||||||
|
|
@ -327,24 +336,58 @@ def _restart_slash_worker(session: dict):
|
||||||
session["slash_worker"] = None
|
session["slash_worker"] = None
|
||||||
|
|
||||||
|
|
||||||
def _apply_model_switch(sid: str, session: dict, raw_input: str) -> dict:
|
def _persist_model_switch(result) -> None:
|
||||||
agent = session.get("agent")
|
from hermes_cli.config import save_config
|
||||||
if not agent:
|
|
||||||
os.environ["HERMES_MODEL"] = raw_input
|
|
||||||
return {"value": raw_input, "warning": ""}
|
|
||||||
|
|
||||||
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(
|
result = switch_model(
|
||||||
raw_input=raw_input,
|
raw_input=model_input,
|
||||||
current_provider=getattr(agent, "provider", "") or "",
|
current_provider=current_provider,
|
||||||
current_model=getattr(agent, "model", "") or "",
|
current_model=current_model,
|
||||||
current_base_url=getattr(agent, "base_url", "") or "",
|
current_base_url=current_base_url,
|
||||||
current_api_key=getattr(agent, "api_key", "") or "",
|
current_api_key=current_api_key,
|
||||||
|
is_global=persist_global,
|
||||||
|
explicit_provider=explicit_provider,
|
||||||
)
|
)
|
||||||
if not result.success:
|
if not result.success:
|
||||||
raise ValueError(result.error_message or "model switch failed")
|
raise ValueError(result.error_message or "model switch failed")
|
||||||
|
|
||||||
|
if agent:
|
||||||
agent.switch_model(
|
agent.switch_model(
|
||||||
new_model=result.new_model,
|
new_model=result.new_model,
|
||||||
new_provider=result.target_provider,
|
new_provider=result.target_provider,
|
||||||
|
|
@ -352,13 +395,16 @@ def _apply_model_switch(sid: str, session: dict, raw_input: str) -> dict:
|
||||||
base_url=result.base_url,
|
base_url=result.base_url,
|
||||||
api_mode=result.api_mode,
|
api_mode=result.api_mode,
|
||||||
)
|
)
|
||||||
os.environ["HERMES_MODEL"] = result.new_model
|
|
||||||
_restart_slash_worker(session)
|
_restart_slash_worker(session)
|
||||||
_emit("session.info", sid, _session_info(agent))
|
_emit("session.info", sid, _session_info(agent))
|
||||||
|
|
||||||
|
os.environ["HERMES_MODEL"] = result.new_model
|
||||||
|
if persist_global:
|
||||||
|
_persist_model_switch(result)
|
||||||
return {"value": result.new_model, "warning": result.warning_message or ""}
|
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
|
from agent.model_metadata import estimate_messages_tokens_rough
|
||||||
|
|
||||||
agent = session["agent"]
|
agent = session["agent"]
|
||||||
|
|
@ -370,6 +416,7 @@ def _compress_session_history(session: dict) -> tuple[int, dict]:
|
||||||
history,
|
history,
|
||||||
getattr(agent, "_cached_system_prompt", "") or "",
|
getattr(agent, "_cached_system_prompt", "") or "",
|
||||||
approx_tokens=approx_tokens,
|
approx_tokens=approx_tokens,
|
||||||
|
focus_topic=focus_topic or None,
|
||||||
)
|
)
|
||||||
session["history"] = compressed
|
session["history"] = compressed
|
||||||
session["history_version"] = int(session.get("history_version", 0)) + 1
|
session["history_version"] = int(session.get("history_version", 0)) + 1
|
||||||
|
|
@ -616,22 +663,92 @@ def _resolve_personality_prompt(cfg: dict) -> str:
|
||||||
name = (cfg.get("display", {}).get("personality", "") or "").strip().lower()
|
name = (cfg.get("display", {}).get("personality", "") or "").strip().lower()
|
||||||
if not name or name in ("default", "none", "neutral"):
|
if not name or name in ("default", "none", "neutral"):
|
||||||
return ""
|
return ""
|
||||||
|
try:
|
||||||
|
from cli import load_cli_config
|
||||||
|
|
||||||
|
personalities = load_cli_config().get("agent", {}).get("personalities", {})
|
||||||
|
except Exception:
|
||||||
try:
|
try:
|
||||||
from hermes_cli.config import load_config as _load_full_cfg
|
from hermes_cli.config import load_config as _load_full_cfg
|
||||||
|
|
||||||
personalities = _load_full_cfg().get("agent", {}).get("personalities", {})
|
personalities = _load_full_cfg().get("agent", {}).get("personalities", {})
|
||||||
except Exception:
|
except Exception:
|
||||||
personalities = cfg.get("agent", {}).get("personalities", {})
|
personalities = cfg.get("agent", {}).get("personalities", {})
|
||||||
pval = personalities.get(name)
|
pval = personalities.get(name)
|
||||||
if pval is None:
|
if pval is None:
|
||||||
return ""
|
return ""
|
||||||
if isinstance(pval, dict):
|
return _render_personality_prompt(pval)
|
||||||
parts = [pval.get("system_prompt", "")]
|
|
||||||
if pval.get("tone"):
|
|
||||||
parts.append(f'Tone: {pval["tone"]}')
|
def _render_personality_prompt(value) -> str:
|
||||||
if pval.get("style"):
|
if isinstance(value, dict):
|
||||||
parts.append(f'Style: {pval["style"]}')
|
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 "\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):
|
def _make_agent(sid: str, key: str, session_id: str | None = None):
|
||||||
|
|
@ -893,9 +1010,11 @@ def _(rid, params: dict) -> dict:
|
||||||
return err
|
return err
|
||||||
try:
|
try:
|
||||||
with session["history_lock"]:
|
with session["history_lock"]:
|
||||||
removed, usage = _compress_session_history(session)
|
removed, usage = _compress_session_history(session, str(params.get("focus_topic", "") or "").strip())
|
||||||
_emit("session.info", params.get("session_id", ""), _session_info(session["agent"]))
|
messages = list(session.get("history", []))
|
||||||
return _ok(rid, {"status": "compressed", "removed": removed, "usage": usage})
|
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:
|
except Exception as e:
|
||||||
return _err(rid, 5005, str(e))
|
return _err(rid, 5005, str(e))
|
||||||
|
|
||||||
|
|
@ -906,7 +1025,7 @@ def _(rid, params: dict) -> dict:
|
||||||
if err:
|
if err:
|
||||||
return err
|
return err
|
||||||
import time as _time
|
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:
|
try:
|
||||||
with open(filename, "w") as f:
|
with open(filename, "w") as f:
|
||||||
json.dump({"model": getattr(session["agent"], "model", ""), "messages": session.get("history", [])},
|
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))
|
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")
|
@method("session.branch")
|
||||||
def _(rid, params: dict) -> dict:
|
def _(rid, params: dict) -> dict:
|
||||||
session, err = _sess(params, rid)
|
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
|
# Save-first: mirrors CLI keybinding path; more robust than has_image() precheck
|
||||||
if not save_clipboard_image(img_path):
|
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"
|
msg = "Clipboard has image but extraction failed" if has_clipboard_image() else "No image found in clipboard"
|
||||||
return _ok(rid, {"attached": False, "message": msg})
|
return _ok(rid, {"attached": False, "message": msg})
|
||||||
|
|
||||||
|
|
@ -1182,6 +1323,9 @@ def _(rid, params: dict) -> dict:
|
||||||
|
|
||||||
@method("prompt.background")
|
@method("prompt.background")
|
||||||
def _(rid, params: dict) -> dict:
|
def _(rid, params: dict) -> dict:
|
||||||
|
session, err = _sess(params, rid)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
text, parent = params.get("text", ""), params.get("session_id", "")
|
text, parent = params.get("text", ""), params.get("session_id", "")
|
||||||
if not text:
|
if not text:
|
||||||
return _err(rid, 4012, "text required")
|
return _err(rid, 4012, "text required")
|
||||||
|
|
@ -1275,8 +1419,7 @@ def _(rid, params: dict) -> dict:
|
||||||
if session:
|
if session:
|
||||||
result = _apply_model_switch(params.get("session_id", ""), session, value)
|
result = _apply_model_switch(params.get("session_id", ""), session, value)
|
||||||
else:
|
else:
|
||||||
os.environ["HERMES_MODEL"] = value
|
result = _apply_model_switch("", {"agent": None}, value)
|
||||||
result = {"value": value, "warning": ""}
|
|
||||||
return _ok(rid, {"key": key, "value": result["value"], "warning": result["warning"]})
|
return _ok(rid, {"key": key, "value": result["value"], "warning": result["warning"]})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return _err(rid, 5001, str(e))
|
return _err(rid, 5001, str(e))
|
||||||
|
|
@ -1368,25 +1511,12 @@ def _(rid, params: dict) -> dict:
|
||||||
nv = value
|
nv = value
|
||||||
_save_cfg(cfg)
|
_save_cfg(cfg)
|
||||||
elif key == "personality":
|
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", "")
|
sid_key = params.get("session_id", "")
|
||||||
if session:
|
pname, new_prompt = _validate_personality(str(value or ""), cfg)
|
||||||
try:
|
_write_config_key("display.personality", pname)
|
||||||
new_agent = _make_agent(sid_key, session["session_key"], session_id=session["session_key"])
|
_write_config_key("agent.system_prompt", new_prompt)
|
||||||
session["agent"] = new_agent
|
nv = str(value or "default")
|
||||||
with session["history_lock"]:
|
history_reset, info = _apply_personality_to_session(sid_key, session, new_prompt)
|
||||||
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
|
|
||||||
else:
|
else:
|
||||||
_write_config_key(f"display.{key}", value)
|
_write_config_key(f"display.{key}", value)
|
||||||
nv = value
|
nv = value
|
||||||
|
|
@ -1394,7 +1524,9 @@ def _(rid, params: dict) -> dict:
|
||||||
_emit("skin.changed", "", resolve_skin())
|
_emit("skin.changed", "", resolve_skin())
|
||||||
resp = {"key": key, "value": nv}
|
resp = {"key": key, "value": nv}
|
||||||
if key == "personality":
|
if key == "personality":
|
||||||
resp["cleared"] = True
|
resp["history_reset"] = history_reset
|
||||||
|
if info is not None:
|
||||||
|
resp["info"] = info
|
||||||
return _ok(rid, resp)
|
return _ok(rid, resp)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return _err(rid, 5001, str(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")})
|
return _ok(rid, {"value": _load_cfg().get("display", {}).get("skin", "default")})
|
||||||
if key == "personality":
|
if key == "personality":
|
||||||
return _ok(rid, {"value": _load_cfg().get("display", {}).get("personality", "default")})
|
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":
|
if key == "mtime":
|
||||||
cfg_path = _hermes_home / "config.yaml"
|
cfg_path = _hermes_home / "config.yaml"
|
||||||
try:
|
try:
|
||||||
|
|
@ -1510,14 +1647,15 @@ def _(rid, params: dict) -> dict:
|
||||||
cat_map[cat].append([name, desc])
|
cat_map[cat].append([name, desc])
|
||||||
|
|
||||||
skill_count = 0
|
skill_count = 0
|
||||||
|
warning = ""
|
||||||
try:
|
try:
|
||||||
from agent.skill_commands import scan_skill_commands
|
from agent.skill_commands import scan_skill_commands
|
||||||
for k, info in sorted(scan_skill_commands().items()):
|
for k, info in sorted(scan_skill_commands().items()):
|
||||||
d = str(info.get("description", "Skill"))
|
d = str(info.get("description", "Skill"))
|
||||||
all_pairs.append([k, d[:120] + ("…" if len(d) > 120 else "")])
|
all_pairs.append([k, d[:120] + ("…" if len(d) > 120 else "")])
|
||||||
skill_count += 1
|
skill_count += 1
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
warning = f"skill discovery unavailable: {e}"
|
||||||
|
|
||||||
for cat in cat_order:
|
for cat in cat_order:
|
||||||
categories.append({"name": cat, "pairs": cat_map[cat]})
|
categories.append({"name": cat, "pairs": cat_map[cat]})
|
||||||
|
|
@ -1529,6 +1667,7 @@ def _(rid, params: dict) -> dict:
|
||||||
"canon": canon,
|
"canon": canon,
|
||||||
"categories": categories,
|
"categories": categories,
|
||||||
"skill_count": skill_count,
|
"skill_count": skill_count,
|
||||||
|
"warning": warning,
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return _err(rid, 5020, str(e))
|
return _err(rid, 5020, str(e))
|
||||||
|
|
@ -1611,7 +1750,10 @@ def _(rid, params: dict) -> dict:
|
||||||
qc = qcmds[name]
|
qc = qcmds[name]
|
||||||
if qc.get("type") == "exec":
|
if qc.get("type") == "exec":
|
||||||
r = subprocess.run(qc.get("command", ""), shell=True, capture_output=True, text=True, timeout=30)
|
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":
|
if qc.get("type") == "alias":
|
||||||
return _ok(rid, {"type": "alias", "target": qc.get("target", "")})
|
return _ok(rid, {"type": "alias", "target": qc.get("target", "")})
|
||||||
|
|
||||||
|
|
@ -1692,15 +1834,18 @@ def _(rid, params: dict) -> dict:
|
||||||
prefix_tag = ""
|
prefix_tag = ""
|
||||||
path_part = query if not is_context else query
|
path_part = query if not is_context else query
|
||||||
|
|
||||||
expanded = os.path.expanduser(path_part)
|
expanded = _normalize_completion_path(path_part)
|
||||||
if expanded.endswith("/"):
|
if expanded.endswith("/"):
|
||||||
search_dir, match = expanded, ""
|
search_dir, match = expanded, ""
|
||||||
else:
|
else:
|
||||||
search_dir = os.path.dirname(expanded) or "."
|
search_dir = os.path.dirname(expanded) or "."
|
||||||
match = os.path.basename(expanded)
|
match = os.path.basename(expanded)
|
||||||
|
|
||||||
|
if not os.path.isdir(search_dir):
|
||||||
|
return _ok(rid, {"items": []})
|
||||||
|
|
||||||
match_lower = match.lower()
|
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):
|
if match and not entry.lower().startswith(match_lower):
|
||||||
continue
|
continue
|
||||||
if is_context and not prefix_tag and entry.startswith("."):
|
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 ""})
|
items.append({"text": text, "display": entry + suffix, "meta": "dir" if is_dir else ""})
|
||||||
if len(items) >= 30:
|
if len(items) >= 30:
|
||||||
break
|
break
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
return _err(rid, 5021, str(e))
|
||||||
|
|
||||||
return _ok(rid, {"items": items})
|
return _ok(rid, {"items": items})
|
||||||
|
|
||||||
|
|
@ -1742,39 +1887,83 @@ def _(rid, params: dict) -> dict:
|
||||||
from prompt_toolkit.document import Document
|
from prompt_toolkit.document import Document
|
||||||
from prompt_toolkit.formatted_text import to_plain_text
|
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))
|
doc = Document(text, len(text))
|
||||||
items = [
|
items = [
|
||||||
{"text": c.text, "display": c.display or c.text,
|
{"text": c.text, "display": c.display or c.text,
|
||||||
"meta": to_plain_text(c.display_meta) if c.display_meta else ""}
|
"meta": to_plain_text(c.display_meta) if c.display_meta else ""}
|
||||||
for c in completer.get_completions(doc, None)
|
for c in completer.get_completions(doc, None)
|
||||||
][:30]
|
][: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})
|
return _ok(rid, {"items": items, "replace_from": text.rfind(" ") + 1 if " " in text else 1})
|
||||||
except Exception:
|
except Exception as e:
|
||||||
return _ok(rid, {"items": []})
|
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 ──────────────────────────────────────────────
|
# ── 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."""
|
"""Apply side effects that must also hit the gateway's live agent."""
|
||||||
parts = command.lstrip("/").split(None, 1)
|
parts = command.lstrip("/").split(None, 1)
|
||||||
if not parts:
|
if not parts:
|
||||||
return
|
return ""
|
||||||
name, arg, agent = parts[0], (parts[1].strip() if len(parts) > 1 else ""), session.get("agent")
|
name, arg, agent = parts[0], (parts[1].strip() if len(parts) > 1 else ""), session.get("agent")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if name == "model" and arg and agent:
|
if name == "model" and arg and agent:
|
||||||
_apply_model_switch(sid, session, arg)
|
result = _apply_model_switch(sid, session, arg)
|
||||||
elif name in ("personality", "prompt") and agent:
|
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()
|
cfg = _load_cfg()
|
||||||
new_prompt = cfg.get("agent", {}).get("system_prompt", "") or ""
|
new_prompt = cfg.get("agent", {}).get("system_prompt", "") or ""
|
||||||
agent.ephemeral_system_prompt = new_prompt or None
|
agent.ephemeral_system_prompt = new_prompt or None
|
||||||
agent._cached_system_prompt = None
|
agent._cached_system_prompt = None
|
||||||
elif name == "compress" and agent:
|
elif name == "compress" and agent:
|
||||||
with session["history_lock"]:
|
with session["history_lock"]:
|
||||||
_compress_session_history(session)
|
_compress_session_history(session, arg)
|
||||||
_emit("session.info", sid, _session_info(agent))
|
_emit("session.info", sid, _session_info(agent))
|
||||||
elif name == "fast" and agent:
|
elif name == "fast" and agent:
|
||||||
mode = arg.lower()
|
mode = arg.lower()
|
||||||
|
|
@ -1788,8 +1977,9 @@ def _mirror_slash_side_effects(sid: str, session: dict, command: str):
|
||||||
elif name == "stop":
|
elif name == "stop":
|
||||||
from tools.process_registry import ProcessRegistry
|
from tools.process_registry import ProcessRegistry
|
||||||
ProcessRegistry().kill_all()
|
ProcessRegistry().kill_all()
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
return f"live session sync failed: {e}"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
@method("slash.exec")
|
@method("slash.exec")
|
||||||
|
|
@ -1812,8 +2002,11 @@ def _(rid, params: dict) -> dict:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
output = worker.run(cmd)
|
output = worker.run(cmd)
|
||||||
_mirror_slash_side_effects(params.get("session_id", ""), session, cmd)
|
warning = _mirror_slash_side_effects(params.get("session_id", ""), session, cmd)
|
||||||
return _ok(rid, {"output": output or "(no output)"})
|
payload = {"output": output or "(no output)"}
|
||||||
|
if warning:
|
||||||
|
payload["warning"] = warning
|
||||||
|
return _ok(rid, payload)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
try:
|
try:
|
||||||
worker.close()
|
worker.close()
|
||||||
|
|
@ -1829,9 +2022,14 @@ def _(rid, params: dict) -> dict:
|
||||||
def _(rid, params: dict) -> dict:
|
def _(rid, params: dict) -> dict:
|
||||||
action = params.get("action", "status")
|
action = params.get("action", "status")
|
||||||
if 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"):
|
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 _ok(rid, {"enabled": action == "on"})
|
||||||
return _err(rid, 4013, f"unknown voice action: {action}")
|
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})
|
return _ok(rid, {"connected": bool(url), "url": url})
|
||||||
if action == "connect":
|
if action == "connect":
|
||||||
url = params.get("url", "http://localhost:9222")
|
url = params.get("url", "http://localhost:9222")
|
||||||
os.environ["BROWSER_CDP_URL"] = url
|
|
||||||
try:
|
try:
|
||||||
|
import urllib.request
|
||||||
|
from urllib.parse import urlparse
|
||||||
from tools.browser_tool import cleanup_all_browsers
|
from tools.browser_tool import cleanup_all_browsers
|
||||||
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:
|
except Exception:
|
||||||
pass
|
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 as e:
|
||||||
|
return _err(rid, 5031, str(e))
|
||||||
return _ok(rid, {"connected": True, "url": url})
|
return _ok(rid, {"connected": True, "url": url})
|
||||||
if action == "disconnect":
|
if action == "disconnect":
|
||||||
os.environ.pop("BROWSER_CDP_URL", None)
|
os.environ.pop("BROWSER_CDP_URL", None)
|
||||||
|
|
@ -1990,8 +2210,8 @@ def _(rid, params: dict) -> dict:
|
||||||
return _ok(rid, {"plugins": [
|
return _ok(rid, {"plugins": [
|
||||||
{"name": n, "version": getattr(i, "version", "?"), "enabled": getattr(i, "enabled", True)}
|
{"name": n, "version": getattr(i, "version", "?"), "enabled": getattr(i, "enabled", True)}
|
||||||
for n, i in get_plugin_manager()._plugins.items()]})
|
for n, i in get_plugin_manager()._plugins.items()]})
|
||||||
except Exception:
|
except Exception as e:
|
||||||
return _ok(rid, {"plugins": []})
|
return _err(rid, 5032, str(e))
|
||||||
|
|
||||||
|
|
||||||
@method("config.show")
|
@method("config.show")
|
||||||
|
|
|
||||||
1
ui-tui/.gitignore
vendored
1
ui-tui/.gitignore
vendored
|
|
@ -1,3 +1,4 @@
|
||||||
dist/
|
dist/
|
||||||
node_modules/
|
node_modules/
|
||||||
src/*.js
|
src/*.js
|
||||||
|
docs/
|
||||||
|
|
@ -9,6 +9,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { Banner, Panel, SessionPanel } from './components/branding.js'
|
import { Banner, Panel, SessionPanel } from './components/branding.js'
|
||||||
import { MaskedPrompt } from './components/maskedPrompt.js'
|
import { MaskedPrompt } from './components/maskedPrompt.js'
|
||||||
import { MessageLine } from './components/messageLine.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 { ApprovalPrompt, ClarifyPrompt } from './components/prompts.js'
|
||||||
import { QueuedMessages } from './components/queuedMessages.js'
|
import { QueuedMessages } from './components/queuedMessages.js'
|
||||||
import { SessionPicker } from './components/sessionPicker.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(' · ')
|
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[] => {
|
const toTranscriptMessages = (rows: unknown): Msg[] => {
|
||||||
if (!Array.isArray(rows)) {
|
if (!Array.isArray(rows)) {
|
||||||
return []
|
return []
|
||||||
|
|
@ -347,6 +359,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
const [approval, setApproval] = useState<ApprovalReq | null>(null)
|
const [approval, setApproval] = useState<ApprovalReq | null>(null)
|
||||||
const [sudo, setSudo] = useState<SudoReq | null>(null)
|
const [sudo, setSudo] = useState<SudoReq | null>(null)
|
||||||
const [secret, setSecret] = useState<SecretReq | null>(null)
|
const [secret, setSecret] = useState<SecretReq | null>(null)
|
||||||
|
const [modelPicker, setModelPicker] = useState(false)
|
||||||
const [picker, setPicker] = useState(false)
|
const [picker, setPicker] = useState(false)
|
||||||
const [reasoning, setReasoning] = useState('')
|
const [reasoning, setReasoning] = useState('')
|
||||||
const [reasoningActive, setReasoningActive] = useState(false)
|
const [reasoningActive, setReasoningActive] = useState(false)
|
||||||
|
|
@ -386,10 +399,12 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
const reasoningStreamingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const reasoningStreamingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const statusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const statusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const busyRef = useRef(busy)
|
const busyRef = useRef(busy)
|
||||||
|
const sidRef = useRef<string | null>(sid)
|
||||||
const onEventRef = useRef<(ev: GatewayEvent) => void>(() => {})
|
const onEventRef = useRef<(ev: GatewayEvent) => void>(() => {})
|
||||||
const configMtimeRef = useRef(0)
|
const configMtimeRef = useRef(0)
|
||||||
colsRef.current = cols
|
colsRef.current = cols
|
||||||
busyRef.current = busy
|
busyRef.current = busy
|
||||||
|
sidRef.current = sid
|
||||||
reasoningRef.current = reasoning
|
reasoningRef.current = reasoning
|
||||||
|
|
||||||
// ── Hooks ────────────────────────────────────────────────────────
|
// ── Hooks ────────────────────────────────────────────────────────
|
||||||
|
|
@ -433,7 +448,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
)
|
)
|
||||||
|
|
||||||
function blocked() {
|
function blocked() {
|
||||||
return !!(clarify || approval || picker || secret || sudo || pager)
|
return !!(clarify || approval || modelPicker || picker || secret || sudo || pager)
|
||||||
}
|
}
|
||||||
|
|
||||||
const empty = !messages.length
|
const empty = !messages.length
|
||||||
|
|
@ -489,6 +504,15 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
[appendMessage]
|
[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) => {
|
const pushActivity = useCallback((text: string, tone: ActivityItem['tone'] = 'info', replaceLabel?: string) => {
|
||||||
setActivity(prev => {
|
setActivity(prev => {
|
||||||
const base = replaceLabel ? prev.filter(a => !sameToolTrailGroup(replaceLabel, a.text)) : prev
|
const base = replaceLabel ? prev.filter(a => !sameToolTrailGroup(replaceLabel, a.text)) : prev
|
||||||
|
|
@ -553,7 +577,10 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
setTrail(turnToolsRef.current.filter(l => !sameToolTrailGroup(label, l)))
|
setTrail(turnToolsRef.current.filter(l => !sameToolTrailGroup(label, l)))
|
||||||
setTurnTrail(turnToolsRef.current)
|
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) {
|
if (answer) {
|
||||||
persistedToolLabelsRef.current.add(label)
|
persistedToolLabelsRef.current.add(label)
|
||||||
|
|
@ -564,14 +591,15 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
tools: [buildToolTrailLine('clarify', clarify.question)]
|
tools: [buildToolTrailLine('clarify', clarify.question)]
|
||||||
})
|
})
|
||||||
appendMessage({ role: 'user', text: answer })
|
appendMessage({ role: 'user', text: answer })
|
||||||
|
setStatus('running…')
|
||||||
} else {
|
} else {
|
||||||
sys('prompt cancelled')
|
sys('prompt cancelled')
|
||||||
}
|
}
|
||||||
|
|
||||||
setClarify(null)
|
setClarify(null)
|
||||||
setStatus('running…')
|
})
|
||||||
},
|
},
|
||||||
[appendMessage, clarify, gw, sys]
|
[appendMessage, clarify, rpc, sys]
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -650,6 +678,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
setVoiceRecording(false)
|
setVoiceRecording(false)
|
||||||
setVoiceProcessing(false)
|
setVoiceProcessing(false)
|
||||||
setSid(null as any) // will be set by caller
|
setSid(null as any) // will be set by caller
|
||||||
|
setInfo(null)
|
||||||
setHistoryItems([])
|
setHistoryItems([])
|
||||||
setMessages([])
|
setMessages([])
|
||||||
setPastes([])
|
setPastes([])
|
||||||
|
|
@ -661,11 +690,64 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
protocolWarnedRef.current = false
|
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 ───────────────────────────────────────────
|
// ── Session management ───────────────────────────────────────────
|
||||||
|
|
||||||
const newSession = useCallback(
|
const newSession = useCallback(
|
||||||
(msg?: string) =>
|
async (msg?: string) => {
|
||||||
rpc('session.create', { cols: colsRef.current }).then((r: any) => {
|
await closeSession(sidRef.current)
|
||||||
|
|
||||||
|
return rpc('session.create', { cols: colsRef.current }).then((r: any) => {
|
||||||
if (!r) {
|
if (!r) {
|
||||||
setStatus('ready')
|
setStatus('ready')
|
||||||
|
|
||||||
|
|
@ -696,15 +778,18 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
if (msg) {
|
if (msg) {
|
||||||
sys(msg)
|
sys(msg)
|
||||||
}
|
}
|
||||||
}),
|
})
|
||||||
[rpc, sys]
|
},
|
||||||
|
[closeSession, rpc, sys]
|
||||||
)
|
)
|
||||||
|
|
||||||
const resumeById = useCallback(
|
const resumeById = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
setPicker(false)
|
setPicker(false)
|
||||||
setStatus('resuming…')
|
setStatus('resuming…')
|
||||||
gw.request('session.resume', { cols: colsRef.current, session_id: id })
|
closeSession(sidRef.current === id ? null : sidRef.current).then(() =>
|
||||||
|
gw
|
||||||
|
.request('session.resume', { cols: colsRef.current, session_id: id })
|
||||||
.then((raw: any) => {
|
.then((raw: any) => {
|
||||||
const r = asRpcResult(raw)
|
const r = asRpcResult(raw)
|
||||||
|
|
||||||
|
|
@ -733,8 +818,9 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
sys(`error: ${e.message}`)
|
sys(`error: ${e.message}`)
|
||||||
setStatus('ready')
|
setStatus('ready')
|
||||||
})
|
})
|
||||||
|
)
|
||||||
},
|
},
|
||||||
[gw, sys]
|
[closeSession, gw, sys]
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Paste pipeline ───────────────────────────────────────────────
|
// ── Paste pipeline ───────────────────────────────────────────────
|
||||||
|
|
@ -815,13 +901,13 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cleanedText = stripTrailingPasteNewlines(text)
|
||||||
|
|
||||||
|
if (!cleanedText || !/[^\n]/.test(cleanedText)) {
|
||||||
if (bracketed) {
|
if (bracketed) {
|
||||||
void paste(true)
|
void paste(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const cleanedText = stripTrailingPasteNewlines(text)
|
|
||||||
|
|
||||||
if (!cleanedText) {
|
|
||||||
return null
|
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) => {
|
.then((r: any) => {
|
||||||
if (r?.matched) {
|
if (r?.matched) {
|
||||||
if (r.is_image) {
|
if (r.is_image) {
|
||||||
|
|
@ -1029,7 +1115,13 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
|
|
||||||
const dispatchSubmission = useCallback(
|
const dispatchSubmission = useCallback(
|
||||||
(full: string) => {
|
(full: string) => {
|
||||||
if (!full.trim() || !sid) {
|
if (!full.trim()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sid) {
|
||||||
|
sys('session not ready yet')
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1040,7 +1132,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
historyDraftRef.current = ''
|
historyDraftRef.current = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
if (full.startsWith('/')) {
|
if (looksLikeSlashCommand(full)) {
|
||||||
appendMessage({ role: 'system', text: full, kind: 'slash' })
|
appendMessage({ role: 'system', text: full, kind: 'slash' })
|
||||||
pushHistory(full)
|
pushHistory(full)
|
||||||
slashRef.current(full)
|
slashRef.current(full)
|
||||||
|
|
@ -1137,17 +1229,34 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
if (clarify) {
|
if (clarify) {
|
||||||
answerClarify('')
|
answerClarify('')
|
||||||
} else if (approval) {
|
} else if (approval) {
|
||||||
gw.request('approval.respond', { choice: 'deny', session_id: sid }).catch(() => {})
|
rpc('approval.respond', { choice: 'deny', session_id: sid }).then(r => {
|
||||||
|
if (!r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setApproval(null)
|
setApproval(null)
|
||||||
sys('denied')
|
sys('denied')
|
||||||
|
})
|
||||||
} else if (sudo) {
|
} else if (sudo) {
|
||||||
gw.request('sudo.respond', { request_id: sudo.requestId, password: '' }).catch(() => {})
|
rpc('sudo.respond', { request_id: sudo.requestId, password: '' }).then(r => {
|
||||||
|
if (!r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setSudo(null)
|
setSudo(null)
|
||||||
sys('sudo cancelled')
|
sys('sudo cancelled')
|
||||||
|
})
|
||||||
} else if (secret) {
|
} else if (secret) {
|
||||||
gw.request('secret.respond', { request_id: secret.requestId, value: '' }).catch(() => {})
|
rpc('secret.respond', { request_id: secret.requestId, value: '' }).then(r => {
|
||||||
|
if (!r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setSecret(null)
|
setSecret(null)
|
||||||
sys('secret entry cancelled')
|
sys('secret entry cancelled')
|
||||||
|
})
|
||||||
|
} else if (modelPicker) {
|
||||||
|
setModelPicker(false)
|
||||||
} else if (picker) {
|
} else if (picker) {
|
||||||
setPicker(false)
|
setPicker(false)
|
||||||
}
|
}
|
||||||
|
|
@ -1164,11 +1273,12 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!inputBuf.length && key.tab && completions.length) {
|
if (key.tab && completions.length) {
|
||||||
const row = completions[compIdx]
|
const row = completions[compIdx]
|
||||||
|
|
||||||
if (row) {
|
if (row?.text) {
|
||||||
setInput(input.slice(0, compReplace) + row.text)
|
const text = input.startsWith('/') && row.text.startsWith('/') && compReplace > 0 ? row.text.slice(1) : row.text
|
||||||
|
setInput(input.slice(0, compReplace) + text)
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|
@ -1255,6 +1365,10 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ctrl(key, ch, 'l')) {
|
if (ctrl(key, ch, 'l')) {
|
||||||
|
if (guardBusySessionSwitch()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setStatus('forging session…')
|
setStatus('forging session…')
|
||||||
newSession()
|
newSession()
|
||||||
|
|
||||||
|
|
@ -1319,6 +1433,10 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
|
|
||||||
const onEvent = useCallback(
|
const onEvent = useCallback(
|
||||||
(ev: GatewayEvent) => {
|
(ev: GatewayEvent) => {
|
||||||
|
if (ev.session_id && sidRef.current && ev.session_id !== sidRef.current && !ev.type.startsWith('gateway.')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const p = ev.payload as any
|
const p = ev.payload as any
|
||||||
|
|
||||||
switch (ev.type) {
|
switch (ev.type) {
|
||||||
|
|
@ -1342,8 +1460,12 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
skillCount: (r.skill_count ?? 0) as number,
|
skillCount: (r.skill_count ?? 0) as number,
|
||||||
sub: (r.sub ?? {}) as Record<string, string[]>
|
sub: (r.sub ?? {}) as Record<string, string[]>
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (r.warning) {
|
||||||
|
pushActivity(String(r.warning), 'warn')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch((e: unknown) => pushActivity(`command catalog unavailable: ${rpcErrorMessage(e)}`, 'warn'))
|
||||||
|
|
||||||
if (STARTUP_RESUME_ID) {
|
if (STARTUP_RESUME_ID) {
|
||||||
setStatus('resuming…')
|
setStatus('resuming…')
|
||||||
|
|
@ -1397,6 +1519,10 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'thinking.delta':
|
case 'thinking.delta':
|
||||||
|
if (p && Object.prototype.hasOwnProperty.call(p, 'text')) {
|
||||||
|
setStatus(p.text ? String(p.text) : busyRef.current ? 'running…' : 'ready')
|
||||||
|
}
|
||||||
|
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'message.start':
|
case 'message.start':
|
||||||
|
|
@ -1438,19 +1564,43 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
|
|
||||||
case 'gateway.stderr':
|
case 'gateway.stderr':
|
||||||
if (p?.line) {
|
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
|
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':
|
case 'gateway.protocol_error':
|
||||||
setStatus('protocol warning')
|
setStatus('protocol warning')
|
||||||
|
|
||||||
|
if (statusTimerRef.current) {
|
||||||
|
clearTimeout(statusTimerRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
statusTimerRef.current = setTimeout(() => {
|
||||||
|
statusTimerRef.current = null
|
||||||
|
setStatus(busyRef.current ? 'running…' : 'ready')
|
||||||
|
}, 4000)
|
||||||
|
|
||||||
if (!protocolWarnedRef.current) {
|
if (!protocolWarnedRef.current) {
|
||||||
protocolWarnedRef.current = true
|
protocolWarnedRef.current = true
|
||||||
pushActivity('protocol noise detected · /logs to inspect', 'warn')
|
pushActivity('protocol noise detected · /logs to inspect', 'warn')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (p?.preview) {
|
||||||
|
pushActivity(`protocol noise: ${String(p.preview).slice(0, 120)}`, 'warn')
|
||||||
|
}
|
||||||
|
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'reasoning.delta':
|
case 'reasoning.delta':
|
||||||
|
|
@ -1663,11 +1813,13 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
bellOnComplete,
|
bellOnComplete,
|
||||||
dequeue,
|
dequeue,
|
||||||
endReasoningPhase,
|
endReasoningPhase,
|
||||||
|
gw,
|
||||||
newSession,
|
newSession,
|
||||||
pruneTransient,
|
pruneTransient,
|
||||||
pulseReasoningStreaming,
|
pulseReasoningStreaming,
|
||||||
pushActivity,
|
pushActivity,
|
||||||
pushTrail,
|
pushTrail,
|
||||||
|
rpc,
|
||||||
sendQueued,
|
sendQueued,
|
||||||
sys,
|
sys,
|
||||||
stdout
|
stdout
|
||||||
|
|
@ -1681,7 +1833,10 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
|
|
||||||
const exitHandler = () => {
|
const exitHandler = () => {
|
||||||
setStatus('gateway exited')
|
setStatus('gateway exited')
|
||||||
exit()
|
setSid(null)
|
||||||
|
setBusy(false)
|
||||||
|
pushActivity('gateway exited · /logs to inspect', 'error')
|
||||||
|
sys('error: gateway exited')
|
||||||
}
|
}
|
||||||
|
|
||||||
gw.on('event', handler)
|
gw.on('event', handler)
|
||||||
|
|
@ -1691,14 +1846,16 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
return () => {
|
return () => {
|
||||||
gw.off('event', handler)
|
gw.off('event', handler)
|
||||||
gw.off('exit', exitHandler)
|
gw.off('exit', exitHandler)
|
||||||
|
gw.kill()
|
||||||
}
|
}
|
||||||
}, [gw, exit])
|
}, [gw, pushActivity, sys])
|
||||||
|
|
||||||
// ── Slash commands ───────────────────────────────────────────────
|
// ── Slash commands ───────────────────────────────────────────────
|
||||||
|
|
||||||
const slash = useCallback(
|
const slash = useCallback(
|
||||||
(cmd: string): boolean => {
|
(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(' ')
|
const arg = rest.join(' ')
|
||||||
|
|
||||||
switch (name) {
|
switch (name) {
|
||||||
|
|
@ -1729,18 +1886,30 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
return true
|
return true
|
||||||
|
|
||||||
case 'clear':
|
case 'clear':
|
||||||
|
if (guardBusySessionSwitch('switch sessions')) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
setStatus('forging session…')
|
setStatus('forging session…')
|
||||||
newSession()
|
newSession()
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|
||||||
case 'new':
|
case 'new':
|
||||||
|
if (guardBusySessionSwitch('switch sessions')) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
setStatus('forging session…')
|
setStatus('forging session…')
|
||||||
newSession('new session started')
|
newSession('new session started')
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|
||||||
case 'resume':
|
case 'resume':
|
||||||
|
if (guardBusySessionSwitch('switch sessions')) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
if (arg) {
|
if (arg) {
|
||||||
resumeById(arg)
|
resumeById(arg)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1750,13 +1919,33 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
return true
|
return true
|
||||||
|
|
||||||
case 'compact':
|
case 'compact':
|
||||||
setCompact(c => (arg ? true : !c))
|
if (arg && !['on', 'off', 'toggle'].includes(arg.trim().toLowerCase())) {
|
||||||
sys(arg ? `compact on, focus: ${arg}` : `compact ${compact ? 'off' : 'on'}`)
|
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
|
return true
|
||||||
case 'copy': {
|
case 'copy': {
|
||||||
const all = messages.filter(m => m.role === 'assistant')
|
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) {
|
if (!target) {
|
||||||
sys('nothing to copy')
|
sys('nothing to copy')
|
||||||
|
|
@ -1765,7 +1954,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
writeOsc52Clipboard(target.text)
|
writeOsc52Clipboard(target.text)
|
||||||
sys('copied to clipboard')
|
sys('sent OSC52 copy sequence (terminal support required)')
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
@ -1815,7 +2004,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
return true
|
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))
|
setPastes(prev => prev.filter(p => p.id !== id))
|
||||||
setInput(v => stripTokens(v, re))
|
setInput(v => stripTokens(v, re))
|
||||||
setInputBuf(prev => prev.map(l => stripTokens(l, re)).filter(Boolean))
|
setInputBuf(prev => prev.map(l => stripTokens(l, re)).filter(Boolean))
|
||||||
|
|
@ -1854,8 +2043,12 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
case 'statusbar':
|
case 'statusbar':
|
||||||
|
|
||||||
case 'sb':
|
case 'sb':
|
||||||
setStatusBar(v => !v)
|
setStatusBar(current => {
|
||||||
sys(`status bar ${statusBar ? 'off' : 'on'}`)
|
const next = !current
|
||||||
|
queueMicrotask(() => sys(`status bar ${next ? 'on' : 'off'}`))
|
||||||
|
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|
||||||
|
|
@ -1873,6 +2066,8 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
|
|
||||||
case 'undo':
|
case 'undo':
|
||||||
if (!sid) {
|
if (!sid) {
|
||||||
|
sys('nothing to undo')
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1882,19 +2077,8 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (r.removed > 0) {
|
if (r.removed > 0) {
|
||||||
setMessages(prev => {
|
setMessages(prev => trimLastExchange(prev))
|
||||||
const q = [...prev]
|
setHistoryItems(prev => trimLastExchange(prev))
|
||||||
|
|
||||||
while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') {
|
|
||||||
q.pop()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (q.at(-1)?.role === 'user') {
|
|
||||||
q.pop()
|
|
||||||
}
|
|
||||||
|
|
||||||
return q
|
|
||||||
})
|
|
||||||
sys(`undid ${r.removed} messages`)
|
sys(`undid ${r.removed} messages`)
|
||||||
} else {
|
} else {
|
||||||
sys('nothing to undo')
|
sys('nothing to undo')
|
||||||
|
|
@ -1911,18 +2095,25 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sid) {
|
if (sid) {
|
||||||
gw.request('session.undo', { session_id: sid }).catch(() => {})
|
rpc('session.undo', { session_id: sid }).then((r: any) => {
|
||||||
|
if (!r) {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setMessages(prev => {
|
if (r.removed <= 0) {
|
||||||
const q = [...prev]
|
sys('nothing to retry')
|
||||||
|
|
||||||
while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') {
|
return
|
||||||
q.pop()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return q
|
setMessages(prev => trimLastExchange(prev))
|
||||||
|
setHistoryItems(prev => trimLastExchange(prev))
|
||||||
|
send(lastUserMsg)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
send(lastUserMsg)
|
send(lastUserMsg)
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|
@ -1966,37 +2157,28 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
return true
|
return true
|
||||||
|
|
||||||
case 'model':
|
case 'model':
|
||||||
|
if (guardBusySessionSwitch('change models')) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
if (!arg) {
|
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) {
|
if (!r) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
panel('Model', [
|
if (!r.value) {
|
||||||
{
|
sys('error: invalid response: model switch')
|
||||||
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sys(`model → ${r.value}`)
|
sys(`model → ${r.value}`)
|
||||||
|
maybeWarn(r)
|
||||||
if (r.warning) {
|
setInfo(prev => (prev ? { ...prev, model: r.value } : { model: r.value, skills: {}, tools: {} }))
|
||||||
sys(`warning: ${r.warning}`)
|
})
|
||||||
}
|
|
||||||
|
|
||||||
setInfo(prev => (prev ? { ...prev, model: r.value } : prev))
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|
@ -2019,7 +2201,12 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
|
|
||||||
case 'provider':
|
case 'provider':
|
||||||
gw.request('slash.exec', { command: 'provider', session_id: sid })
|
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)}`))
|
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|
@ -2057,13 +2244,23 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
return true
|
return true
|
||||||
|
|
||||||
case 'reasoning':
|
case 'reasoning':
|
||||||
rpc('config.set', { session_id: sid, key: 'reasoning', value: arg || 'medium' }).then((r: any) => {
|
if (!arg) {
|
||||||
|
rpc('config.get', { key: 'reasoning' }).then((r: any) => {
|
||||||
|
if (!r?.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
if (!r?.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sys(`reasoning: ${r.value}`)
|
sys(`reasoning: ${r.value}`)
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|
||||||
|
|
@ -2080,28 +2277,61 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
|
|
||||||
case 'personality':
|
case 'personality':
|
||||||
if (arg) {
|
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) {
|
if (!r) {
|
||||||
return
|
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 {
|
} else {
|
||||||
gw.request('slash.exec', { command: 'personality', session_id: sid })
|
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)}`))
|
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|
||||||
case 'compress':
|
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) {
|
if (!r) {
|
||||||
return
|
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
|
return true
|
||||||
|
|
@ -2112,7 +2342,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sys(`killed ${r.killed ?? 0} process(es)`)
|
sys(`killed ${r.killed ?? 0} registered process(es)`)
|
||||||
})
|
})
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|
@ -2120,8 +2350,11 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
case 'branch':
|
case 'branch':
|
||||||
|
|
||||||
case 'fork':
|
case 'fork':
|
||||||
|
{
|
||||||
|
const prevSid = sid
|
||||||
rpc('session.branch', { session_id: sid, name: arg }).then((r: any) => {
|
rpc('session.branch', { session_id: sid, name: arg }).then((r: any) => {
|
||||||
if (r?.session_id) {
|
if (r?.session_id) {
|
||||||
|
void closeSession(prevSid)
|
||||||
setSid(r.session_id)
|
setSid(r.session_id)
|
||||||
setSessionStartedAt(Date.now())
|
setSessionStartedAt(Date.now())
|
||||||
setHistoryItems([])
|
setHistoryItems([])
|
||||||
|
|
@ -2129,6 +2362,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
sys(`branched → ${r.title}`)
|
sys(`branched → ${r.title}`)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|
||||||
|
|
@ -2249,7 +2483,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
setVoiceEnabled(!!r?.enabled)
|
setVoiceEnabled(!!r?.enabled)
|
||||||
sys(`voice${arg === 'on' || arg === 'off' ? '' : ':'} ${r.enabled ? 'on' : 'off'}`)
|
sys(`voice: ${r.enabled ? 'on' : 'off'}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|
@ -2411,7 +2645,13 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
gw.request('slash.exec', { command: cmd.slice(1), session_id: sid })
|
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)}`))
|
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|
@ -2481,7 +2721,9 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
|
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
|
||||||
} else {
|
} else {
|
||||||
gw.request('slash.exec', { command: cmd.slice(1), session_id: sid })
|
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)}`))
|
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2558,14 +2800,20 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
|
|
||||||
default:
|
default:
|
||||||
gw.request('slash.exec', { command: cmd.slice(1), session_id: sid })
|
gw.request('slash.exec', { command: cmd.slice(1), session_id: sid })
|
||||||
.then((r: any) => sys(r?.output || `/${name}: no output`))
|
.then((r: any) => {
|
||||||
.catch((e: unknown) => {
|
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 })
|
gw.request('command.dispatch', { name: name ?? '', arg, session_id: sid })
|
||||||
.then((raw: any) => {
|
.then((raw: any) => {
|
||||||
const d = asRpcResult(raw)
|
const d = asRpcResult(raw)
|
||||||
|
|
||||||
if (!d?.type) {
|
if (!d?.type) {
|
||||||
sys(`error: ${rpcErrorMessage(e)}`)
|
sys('error: invalid response: command.dispatch')
|
||||||
|
|
||||||
return
|
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
|
return true
|
||||||
|
|
@ -2595,8 +2843,10 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
[
|
[
|
||||||
catalog,
|
catalog,
|
||||||
compact,
|
compact,
|
||||||
|
guardBusySessionSwitch,
|
||||||
gw,
|
gw,
|
||||||
lastUserMsg,
|
lastUserMsg,
|
||||||
|
maybeWarn,
|
||||||
messages,
|
messages,
|
||||||
newSession,
|
newSession,
|
||||||
page,
|
page,
|
||||||
|
|
@ -2604,6 +2854,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
pastes,
|
pastes,
|
||||||
pushActivity,
|
pushActivity,
|
||||||
rpc,
|
rpc,
|
||||||
|
resetVisibleHistory,
|
||||||
send,
|
send,
|
||||||
sid,
|
sid,
|
||||||
statusBar,
|
statusBar,
|
||||||
|
|
@ -2755,10 +3006,15 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
<PromptBox color={theme.color.bronze}>
|
<PromptBox color={theme.color.bronze}>
|
||||||
<ApprovalPrompt
|
<ApprovalPrompt
|
||||||
onChoice={choice => {
|
onChoice={choice => {
|
||||||
gw.request('approval.respond', { choice, session_id: sid }).catch(() => {})
|
rpc('approval.respond', { choice, session_id: sid }).then(r => {
|
||||||
|
if (!r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setApproval(null)
|
setApproval(null)
|
||||||
sys(choice === 'deny' ? 'denied' : `approved (${choice})`)
|
sys(choice === 'deny' ? 'denied' : `approved (${choice})`)
|
||||||
setStatus('running…')
|
setStatus('running…')
|
||||||
|
})
|
||||||
}}
|
}}
|
||||||
req={approval}
|
req={approval}
|
||||||
t={theme}
|
t={theme}
|
||||||
|
|
@ -2773,9 +3029,14 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
icon="🔐"
|
icon="🔐"
|
||||||
label="sudo password required"
|
label="sudo password required"
|
||||||
onSubmit={pw => {
|
onSubmit={pw => {
|
||||||
gw.request('sudo.respond', { request_id: sudo.requestId, password: pw }).catch(() => {})
|
rpc('sudo.respond', { request_id: sudo.requestId, password: pw }).then(r => {
|
||||||
|
if (!r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setSudo(null)
|
setSudo(null)
|
||||||
setStatus('running…')
|
setStatus('running…')
|
||||||
|
})
|
||||||
}}
|
}}
|
||||||
t={theme}
|
t={theme}
|
||||||
/>
|
/>
|
||||||
|
|
@ -2789,9 +3050,14 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
icon="🔑"
|
icon="🔑"
|
||||||
label={secret.prompt}
|
label={secret.prompt}
|
||||||
onSubmit={val => {
|
onSubmit={val => {
|
||||||
gw.request('secret.respond', { request_id: secret.requestId, value: val }).catch(() => {})
|
rpc('secret.respond', { request_id: secret.requestId, value: val }).then(r => {
|
||||||
|
if (!r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setSecret(null)
|
setSecret(null)
|
||||||
setStatus('running…')
|
setStatus('running…')
|
||||||
|
})
|
||||||
}}
|
}}
|
||||||
sub={`for ${secret.envVar}`}
|
sub={`for ${secret.envVar}`}
|
||||||
t={theme}
|
t={theme}
|
||||||
|
|
@ -2805,11 +3071,28 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
</PromptBox>
|
</PromptBox>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{modelPicker && (
|
||||||
|
<PromptBox color={theme.color.bronze}>
|
||||||
|
<ModelPicker
|
||||||
|
gw={gw}
|
||||||
|
onCancel={() => setModelPicker(false)}
|
||||||
|
onSelect={value => {
|
||||||
|
setModelPicker(false)
|
||||||
|
slash(`/model ${value}`)
|
||||||
|
}}
|
||||||
|
sessionId={sid}
|
||||||
|
t={theme}
|
||||||
|
/>
|
||||||
|
</PromptBox>
|
||||||
|
)}
|
||||||
|
|
||||||
<QueuedMessages cols={cols} queued={queuedDisplay} queueEditIdx={queueEditIdx} t={theme} />
|
<QueuedMessages cols={cols} queued={queuedDisplay} queueEditIdx={queueEditIdx} t={theme} />
|
||||||
|
|
||||||
|
<PasteShelf draft={[...inputBuf, input].join('\n')} pastes={pastes} t={theme} />
|
||||||
|
|
||||||
{bgTasks.size > 0 && (
|
{bgTasks.size > 0 && (
|
||||||
<Text color={theme.color.dim} dimColor>
|
<Text color={theme.color.dim} dimColor>
|
||||||
{bgTasks.size} background {bgTasks.size === 1 ? 'task' : 'tasks'} running · /stop to cancel
|
{bgTasks.size} background {bgTasks.size === 1 ? 'task' : 'tasks'} running
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
241
ui-tui/src/components/modelPicker.tsx
Normal file
241
ui-tui/src/components/modelPicker.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -30,10 +30,68 @@ const dim = (s: string) => DIM + s + DIM_OFF
|
||||||
let _seg: Intl.Segmenter | null = null
|
let _seg: Intl.Segmenter | null = null
|
||||||
const seg = () => (_seg ??= new Intl.Segmenter(undefined, { granularity: 'grapheme' }))
|
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 ────────────────────────────────────────────────────
|
// ── Word movement ────────────────────────────────────────────────────
|
||||||
|
|
||||||
function wordLeft(s: string, p: number) {
|
function wordLeft(s: string, p: number) {
|
||||||
let i = p - 1
|
let i = snapPos(s, p) - 1
|
||||||
|
|
||||||
while (i > 0 && /\s/.test(s[i]!)) {
|
while (i > 0 && /\s/.test(s[i]!)) {
|
||||||
i--
|
i--
|
||||||
|
|
@ -47,7 +105,7 @@ function wordLeft(s: string, p: number) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function wordRight(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]!)) {
|
while (i < s.length && !/\s/.test(s[i]!)) {
|
||||||
i++
|
i++
|
||||||
|
|
@ -252,7 +310,7 @@ export function TextInput({
|
||||||
|
|
||||||
const commit = (next: string, nextCur: number, track = true) => {
|
const commit = (next: string, nextCur: number, track = true) => {
|
||||||
const prev = vRef.current
|
const prev = vRef.current
|
||||||
const c = Math.max(0, Math.min(nextCur, next.length))
|
const c = snapPos(next, nextCur)
|
||||||
|
|
||||||
if (track && next !== prev) {
|
if (track && next !== prev) {
|
||||||
undo.current.push({ cursor: curRef.current, value: prev })
|
undo.current.push({ cursor: curRef.current, value: prev })
|
||||||
|
|
@ -316,11 +374,10 @@ export function TextInput({
|
||||||
|
|
||||||
useInput(
|
useInput(
|
||||||
(inp: string, k: Key, event: InputEvent) => {
|
(inp: string, k: Key, event: InputEvent) => {
|
||||||
// Some terminals normalize Ctrl+V to "v"; others deliver raw ^V (\x16).
|
const raw = event.keypress.raw
|
||||||
const ctrlPaste = k.ctrl && (inp.toLowerCase() === 'v' || event.keypress.raw === '\x16')
|
const metaPaste = raw === '\x1bv' || raw === '\x1bV'
|
||||||
const metaPaste = k.meta && inp.toLowerCase() === 'v'
|
|
||||||
|
|
||||||
if (ctrlPaste || metaPaste) {
|
if (metaPaste) {
|
||||||
return void emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current })
|
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')) {
|
} else if (k.end || (k.ctrl && inp === 'e')) {
|
||||||
c = v.length
|
c = v.length
|
||||||
} else if (k.leftArrow) {
|
} 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) {
|
} 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') {
|
} else if (k.meta && inp === 'b') {
|
||||||
c = wordLeft(v, c)
|
c = wordLeft(v, c)
|
||||||
} else if (k.meta && inp === 'f') {
|
} else if (k.meta && inp === 'f') {
|
||||||
|
|
@ -382,15 +439,16 @@ export function TextInput({
|
||||||
v = v.slice(0, t) + v.slice(c)
|
v = v.slice(0, t) + v.slice(c)
|
||||||
c = t
|
c = t
|
||||||
} else {
|
} else {
|
||||||
v = v.slice(0, c - 1) + v.slice(c)
|
const t = prevPos(v, c)
|
||||||
c--
|
v = v.slice(0, t) + v.slice(c)
|
||||||
|
c = t
|
||||||
}
|
}
|
||||||
} else if (k.delete && fwdDel.current && c < v.length) {
|
} else if (k.delete && fwdDel.current && c < v.length) {
|
||||||
if (mod) {
|
if (mod) {
|
||||||
const t = wordRight(v, c)
|
const t = wordRight(v, c)
|
||||||
v = v.slice(0, c) + v.slice(t)
|
v = v.slice(0, c) + v.slice(t)
|
||||||
} else {
|
} 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) {
|
} else if (k.ctrl && inp === 'w' && c > 0) {
|
||||||
const t = wordLeft(v, c)
|
const t = wordLeft(v, c)
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ export const HOTKEYS: [string, string][] = [
|
||||||
['Ctrl+G', 'open $EDITOR for prompt'],
|
['Ctrl+G', 'open $EDITOR for prompt'],
|
||||||
['Ctrl+L', 'new session (clear)'],
|
['Ctrl+L', 'new session (clear)'],
|
||||||
['Ctrl+T', 'cycle thinking detail'],
|
['Ctrl+T', 'cycle thinking detail'],
|
||||||
['Ctrl+V / Alt+V', 'paste clipboard image'],
|
['Alt+V / /paste', 'paste clipboard image'],
|
||||||
['Tab', 'apply completion'],
|
['Tab', 'apply completion'],
|
||||||
['↑/↓', 'completions / queue edit / history'],
|
['↑/↓', 'completions / queue edit / history'],
|
||||||
['Ctrl+A/E', 'home / end of line'],
|
['Ctrl+A/E', 'home / end of line'],
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import { createInterface } from 'node:readline'
|
||||||
|
|
||||||
const MAX_GATEWAY_LOG_LINES = 200
|
const MAX_GATEWAY_LOG_LINES = 200
|
||||||
const MAX_LOG_PREVIEW = 240
|
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 {
|
export interface GatewayEvent {
|
||||||
type: string
|
type: string
|
||||||
|
|
@ -23,27 +25,78 @@ export class GatewayClient extends EventEmitter {
|
||||||
private logs: string[] = []
|
private logs: string[] = []
|
||||||
private pending = new Map<string, Pending>()
|
private pending = new Map<string, Pending>()
|
||||||
private bufferedEvents: GatewayEvent[] = []
|
private bufferedEvents: GatewayEvent[] = []
|
||||||
|
private pendingExit: number | null | undefined
|
||||||
|
private ready = false
|
||||||
|
private readyTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
private subscribed = false
|
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() {
|
start() {
|
||||||
const root = process.env.HERMES_PYTHON_SRC_ROOT ?? resolve(import.meta.dirname, '../../')
|
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'], {
|
if (this.proc && !this.proc.killed && this.proc.exitCode === null) {
|
||||||
cwd: process.env.HERMES_CWD || root,
|
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']
|
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 {
|
try {
|
||||||
this.dispatch(JSON.parse(raw))
|
this.dispatch(JSON.parse(raw))
|
||||||
} catch {
|
} catch {
|
||||||
const preview = raw.trim().slice(0, MAX_LOG_PREVIEW) || '(empty line)'
|
const preview = raw.trim().slice(0, MAX_LOG_PREVIEW) || '(empty line)'
|
||||||
this.pushLog(`[protocol] malformed stdout: ${preview}`)
|
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()
|
const line = raw.trim()
|
||||||
|
|
||||||
if (!line) {
|
if (!line) {
|
||||||
|
|
@ -51,18 +104,28 @@ export class GatewayClient extends EventEmitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.pushLog(line)
|
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.proc.on('error', err => {
|
||||||
this.pushLog(`[spawn] ${err.message}`)
|
this.pushLog(`[spawn] ${err.message}`)
|
||||||
this.rejectPending(new Error(`gateway error: ${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 => {
|
this.proc.on('exit', code => {
|
||||||
|
if (this.readyTimer) {
|
||||||
|
clearTimeout(this.readyTimer)
|
||||||
|
this.readyTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
this.rejectPending(new Error(`gateway exited${code === null ? '' : ` (${code})`}`))
|
this.rejectPending(new Error(`gateway exited${code === null ? '' : ` (${code})`}`))
|
||||||
|
|
||||||
|
if (this.subscribed) {
|
||||||
this.emit('exit', code)
|
this.emit('exit', code)
|
||||||
|
} else {
|
||||||
|
this.pendingExit = code
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -78,13 +141,7 @@ export class GatewayClient extends EventEmitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.method === 'event') {
|
if (msg.method === 'event') {
|
||||||
const ev = msg.params as GatewayEvent
|
this.publish(msg.params as GatewayEvent)
|
||||||
|
|
||||||
if (this.subscribed) {
|
|
||||||
this.emit('event', ev)
|
|
||||||
} else {
|
|
||||||
this.bufferedEvents.push(ev)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -110,6 +167,12 @@ export class GatewayClient extends EventEmitter {
|
||||||
for (const ev of pending) {
|
for (const ev of pending) {
|
||||||
this.emit('event', ev)
|
this.emit('event', ev)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.pendingExit !== undefined) {
|
||||||
|
const code = this.pendingExit
|
||||||
|
this.pendingExit = undefined
|
||||||
|
this.emit('exit', code)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getLogTail(limit = 20): string {
|
getLogTail(limit = 20): string {
|
||||||
|
|
@ -117,6 +180,10 @@ export class GatewayClient extends EventEmitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
request(method: string, params: Record<string, unknown> = {}): Promise<unknown> {
|
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) {
|
if (!this.proc?.stdin) {
|
||||||
return Promise.reject(new Error('gateway not running'))
|
return Promise.reject(new Error('gateway not running'))
|
||||||
}
|
}
|
||||||
|
|
@ -128,7 +195,7 @@ export class GatewayClient extends EventEmitter {
|
||||||
if (this.pending.delete(id)) {
|
if (this.pending.delete(id)) {
|
||||||
reject(new Error(`timeout: ${method}`))
|
reject(new Error(`timeout: ${method}`))
|
||||||
}
|
}
|
||||||
}, 30_000)
|
}, REQUEST_TIMEOUT_MS)
|
||||||
|
|
||||||
this.pending.set(id, {
|
this.pending.set(id, {
|
||||||
reject: e => {
|
reject: e => {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { startTransition, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
import type { GatewayClient } from '../gatewayClient.js'
|
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) {
|
export function useCompletion(input: string, blocked: boolean, gw: GatewayClient) {
|
||||||
const [completions, setCompletions] = useState<{ text: string; display: string; meta: string }[]>([])
|
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))
|
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)
|
}, 60)
|
||||||
|
|
||||||
return () => clearTimeout(t)
|
return () => clearTimeout(t)
|
||||||
|
|
|
||||||
|
|
@ -243,4 +243,4 @@ export const userDisplay = (text: string): string => {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isPasteBackedText = (text: string): boolean =>
|
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)
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ export interface SessionInfo {
|
||||||
tools: Record<string, string[]>
|
tools: Record<string, string[]>
|
||||||
update_behind?: number | null
|
update_behind?: number | null
|
||||||
update_command?: string
|
update_command?: string
|
||||||
|
usage?: Usage
|
||||||
version?: string
|
version?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue