hermes-agent/tui_gateway/methods_complete.py

471 lines
18 KiB
Python

"""Completion / model-key / paste JSON-RPC handlers (moved verbatim from server.py).
Handler bodies are byte-identical to their pre-split server.py form; they
are rebound onto server.py's globals at install time — see method_ctx.py.
"""
from .method_ctx import HandlerRegistry
_registry = HandlerRegistry()
method = _registry.method
_profile_scoped = _registry.profile_scoped
@method("paste.collapse")
def _(rid, params: dict) -> dict:
global _paste_counter
text = params.get("text", "")
if not text:
return _err(rid, 4004, "empty paste")
_paste_counter += 1
line_count = text.count("\n") + 1
paste_dir = _hermes_home / "pastes"
paste_dir.mkdir(parents=True, exist_ok=True)
from datetime import datetime
paste_file = (
paste_dir / f"paste_{_paste_counter}_{datetime.now().strftime('%H%M%S')}.txt"
)
paste_file.write_text(text, encoding="utf-8")
placeholder = (
f"[Pasted text #{_paste_counter}: {line_count} lines \u2192 {paste_file}]"
)
return _ok(
rid, {"placeholder": placeholder, "path": str(paste_file), "lines": line_count}
)
@method("complete.path")
def _(rid, params: dict) -> dict:
word = params.get("word", "")
if not word:
return _ok(rid, {"items": []})
items: list[dict] = []
try:
root = _completion_cwd(params)
is_context = word.startswith("@")
query = word[1:] if is_context else word
if is_context and not query:
items = [
{"text": "@diff", "display": "@diff", "meta": "git diff"},
{"text": "@staged", "display": "@staged", "meta": "staged diff"},
{"text": "@file:", "display": "@file:", "meta": "attach file"},
{"text": "@folder:", "display": "@folder:", "meta": "attach folder"},
{"text": "@url:", "display": "@url:", "meta": "fetch url"},
{"text": "@git:", "display": "@git:", "meta": "git log"},
]
return _ok(rid, {"items": items})
# Accept both `@folder:path` and the bare `@folder` form so the user
# sees directory listings as soon as they finish typing the keyword,
# without first accepting the static `@folder:` hint.
if is_context and query in {"file", "folder"}:
prefix_tag, path_part = query, ""
elif is_context and query.startswith(("file:", "folder:")):
prefix_tag, _, tail = query.partition(":")
path_part = tail
else:
prefix_tag = ""
path_part = query if is_context else query
# `@/foo` almost always means "foo, from here" rather than the absolute
# `/foo`: the `@` already says "this is a path", so the slash reads as a
# separator people type out of habit. Take the absolute reading only
# when something is actually there, else drop the slash and resolve
# relative to the cwd — otherwise `@/Desktop` dead-ends on a directory
# that exists one level down. Real absolute paths (`@/usr/local`,
# `@/etc/hosts`) still resolve, since those prefixes do exist.
if (
is_context
and path_part.startswith("/")
and not path_part.startswith("//")
and not _abs_completion_prefix_exists(path_part)
):
path_part = path_part.lstrip("/")
# Fuzzy basename search across the repo when the user types a bare
# name with no path separator — `@appChrome` surfaces every file
# whose basename matches, regardless of directory depth. Matches what
# editors like Cursor / VS Code do for Cmd-P. Path-ish queries (with
# `/`, `./`, `~/`, `/abs`) fall through to the directory-listing
# path so explicit navigation intent is preserved.
if (
is_context
and path_part
and len(path_part.strip()) >= 2
and "/" not in path_part
and prefix_tag != "folder"
):
ranked: list[tuple[tuple[int, int], str, str, bool]] = []
walked_dirs: set[str] = set()
seen: set[str] = set()
want_hidden = path_part.startswith(".")
def _consider(rel: str, name: str, is_dir: bool) -> None:
if rel in seen or (name.startswith(".") and not want_hidden):
return
rank = _fuzzy_basename_rank(name, path_part)
if rank is not None:
seen.add(rel)
ranked.append((rank, rel, name, is_dir))
# Seed with root's immediate children. `_list_repo_files` is capped
# at _FUZZY_CACHE_MAX_FILES, and outside a git repo the fallback
# walk can burn that whole budget on one deep subtree before ever
# reaching a sibling — which is why `@Desk` in a non-repo $HOME
# found nothing. One listdir keeps the top level always reachable.
try:
for entry in os.listdir(root):
if entry not in _FUZZY_FALLBACK_EXCLUDES:
_consider(entry, entry, os.path.isdir(os.path.join(root, entry)))
except OSError:
pass
for rel in _list_repo_files(root):
_consider(rel, os.path.basename(rel), False)
# Directories are only implied by the file listing, so rank each
# ancestor too. Without this a bare `@Desktop` finds nothing —
# a folder with no name-matching file inside it is invisible to
# a file-only scan, which is the "can't @ a folder by name" bug.
parent = os.path.dirname(rel)
while parent and parent not in walked_dirs:
walked_dirs.add(parent)
_consider(parent, os.path.basename(parent), True)
parent = os.path.dirname(parent)
# Same rank tier: folders first, so `@Desktop` leads with the folder
# rather than a file that merely fuzzy-matches the same letters.
ranked.sort(key=lambda r: (r[0], not r[3], len(r[1]), r[1]))
tag = prefix_tag or "file"
for _, rel, basename, is_dir in ranked[:30]:
items.append(
{
"text": f"@{'folder' if is_dir else tag}:{rel}{'/' if is_dir else ''}",
"display": basename + ("/" if is_dir else ""),
"meta": "dir" if is_dir else os.path.dirname(rel),
}
)
return _ok(rid, {"items": items})
expanded = _normalize_completion_path(path_part) if path_part else "."
if expanded == "." or not expanded:
search_dir, match = ".", ""
elif expanded.endswith("/"):
search_dir, match = expanded, ""
else:
search_dir = os.path.dirname(expanded) or "."
match = os.path.basename(expanded)
search_dir = (
search_dir if os.path.isabs(search_dir) else os.path.join(root, search_dir)
)
if not os.path.isdir(search_dir):
return _ok(rid, {"items": []})
want_dir = prefix_tag == "folder"
match_lower = match.lower()
for entry in sorted(os.listdir(search_dir)):
if match and not entry.lower().startswith(match_lower):
continue
if is_context and entry in _FUZZY_FALLBACK_EXCLUDES:
continue
if is_context and not prefix_tag and entry.startswith("."):
continue
full = os.path.join(search_dir, entry)
is_dir = os.path.isdir(full)
# Explicit `@folder:` / `@file:` — honour the user's filter. Skip
# the opposite kind instead of auto-rewriting the completion tag,
# which used to defeat the prefix and let `@folder:` list files.
if prefix_tag and want_dir != is_dir:
continue
rel = os.path.relpath(full, root).replace(os.sep, "/")
suffix = "/" if is_dir else ""
if is_context and prefix_tag:
text = f"@{prefix_tag}:{rel}{suffix}"
elif is_context:
kind = "folder" if is_dir else "file"
text = f"@{kind}:{rel}{suffix}"
elif word.startswith("~"):
text = "~/" + os.path.relpath(full, os.path.expanduser("~")) + suffix
elif word.startswith("./"):
text = "./" + rel + suffix
else:
text = rel + suffix
items.append(
{
"text": text,
"display": entry + suffix,
"meta": "dir" if is_dir else "",
}
)
if len(items) >= 30:
break
except Exception as e:
return _err(rid, 5021, str(e))
return _ok(rid, {"items": items})
@method("complete.slash")
def _(rid, params: dict) -> dict:
text = params.get("text", "")
if not text.startswith("/"):
return _ok(rid, {"items": []})
try:
from hermes_cli.commands import SlashCommandCompleter
from prompt_toolkit.document import Document
from prompt_toolkit.formatted_text import to_plain_text
from agent.skill_commands import get_skill_commands
from agent.skill_bundles import get_skill_bundles
completer = SlashCommandCompleter(
skill_commands_provider=lambda: get_skill_commands(),
skill_bundles_provider=lambda: get_skill_bundles(),
)
doc = Document(text, len(text))
# Skill commands and bundles are the only completions offered for an
# inline `/skill` reference typed mid-message, so the class has to
# reach the TUI as data. Derived from the same providers the completer
# uses — no sniffing the ⚡/▣ meta glyphs, which are display text.
skill_names = {
key.lstrip("/").lower()
for key in (*get_skill_commands(), *get_skill_bundles())
}
items = [
{
"text": c.text,
# prompt_toolkit gives us FormattedText (a list of (style,
# text) tuples) for display/display_meta. Serialize both as
# plain strings — the TUI's CompletionItem.display contract
# is a string, and sending the raw list trips Ink's row
# layout into 1-char truncation of the next column.
"display": to_plain_text(c.display) if c.display else c.text,
"meta": to_plain_text(c.display_meta) if c.display_meta else "",
"kind": (
"skill"
if c.text.strip().lstrip("/").lower() in skill_names
else "command"
),
}
for c in completer.get_completions(doc, None)
][:30]
text_lower = text.lower()
extras = [
{
"text": "/density",
"display": "/density",
"meta": "Toggle compact display mode",
"kind": "command",
},
{
"text": "/details",
"display": "/details",
"meta": "Control agent detail visibility",
"kind": "command",
},
{
"text": "/logs",
"display": "/logs",
"meta": "Show recent gateway log lines",
"kind": "command",
},
{
"text": "/mouse",
"display": "/mouse",
"meta": "Set mouse tracking preset [on|off|toggle|wheel|buttons|all]",
"kind": "command",
},
]
for extra in extras:
if extra["text"].startswith(text_lower) and not any(
item["text"] == extra["text"] for item in items
):
items.append(extra)
details_items = _details_completions(text)
if details_items is not None:
return _ok(
rid,
{
"items": details_items,
"replace_from": text.rfind(" ") + 1 if " " in text else len(text),
},
)
return _ok(
rid,
{"items": items, "replace_from": text.rfind(" ") + 1 if " " in text else 1},
)
except Exception as e:
return _err(rid, 5020, str(e))
@method("model.options")
def _(rid, params: dict) -> dict:
try:
from hermes_cli.inventory import build_model_options_payload
session = _sessions.get(params.get("session_id", ""))
agent = session.get("agent") if session else None
# Layer agent-session state on top of disk config — once an agent
# is spawned, IT owns the live provider/model/base_url. Empty
# agent attributes must NOT clobber disk config (with_overrides
# is truthy-only).
ctx = _model_picker_context(agent)
payload = build_model_options_payload(
ctx,
explicit_only=bool(params.get("explicit_only")),
include_unconfigured=bool(params.get("include_unconfigured")),
refresh=bool(params.get("refresh")),
)
return _ok(rid, payload)
except Exception as e:
return _err(rid, 5033, str(e))
@method("model.save_key")
def _(rid, params: dict) -> dict:
"""Save an API key for a provider, then return its refreshed model list.
Params:
slug: provider slug (e.g. "deepseek", "xai")
api_key: the key value to save
Returns the provider dict with models populated (same shape as
model.options entries) on success.
"""
try:
from hermes_cli.auth import PROVIDER_REGISTRY
from hermes_cli.config import is_managed
from hermes_cli.inventory import build_models_payload
slug = (params.get("slug") or "").strip()
api_key = (params.get("api_key") or "").strip()
if not slug or not api_key:
return _err(rid, 4001, "slug and api_key are required")
if is_managed():
return _err(rid, 4006, "managed install — credentials are read-only")
pconfig = PROVIDER_REGISTRY.get(slug)
if not pconfig:
return _err(rid, 4002, f"unknown provider: {slug}")
if pconfig.auth_type != "api_key":
return _err(
rid,
4003,
f"{pconfig.name} uses {pconfig.auth_type} auth — "
f"run `hermes model` to configure",
)
if not pconfig.api_key_env_vars:
return _err(rid, 4004, f"no env var defined for {pconfig.name}")
# Save the key to ~/.hermes/.env via the unified credential lifecycle
# so any stale config.yaml mirror of the previous key (model.api_key,
# custom_providers[*].api_key) is rotated in the same action (#62269).
env_var = pconfig.api_key_env_vars[0]
from hermes_cli.credential_lifecycle import save_provider_env_credential
save_provider_env_credential(env_var, api_key)
# Also set in current process so the refreshed inventory sees it.
import os
os.environ[env_var] = api_key
# Refresh provider data via the shared inventory builder so this
# surface stays in lock-step with model.options + dashboard
# /api/model/options. picker_hints=True ensures the returned row
# carries `authenticated` for the TUI frontend.
session = _sessions.get(params.get("session_id", ""))
agent = session.get("agent") if session else None
ctx = _model_picker_context(agent)
payload = build_models_payload(
ctx, picker_hints=True, max_models=50,
)
provider_data = next(
(p for p in payload["providers"] if p["slug"] == slug), None
)
if provider_data is None:
# Key was saved but provider didn't appear — still return success.
provider_data = {
"slug": slug,
"name": pconfig.name,
"is_current": False,
"models": [],
"total_models": 0,
"authenticated": True,
}
# picker_hints sets `authenticated` from the row state, but the
# synthetic fallback above doesn't go through that path.
provider_data["authenticated"] = True
return _ok(rid, {"provider": provider_data})
except Exception as e:
return _err(rid, 5034, str(e))
@method("model.disconnect")
def _(rid, params: dict) -> dict:
"""Remove credentials for a provider.
Params:
slug: provider slug (e.g. "deepseek", "xai")
Returns success status and the provider's slug.
"""
try:
from hermes_cli.auth import PROVIDER_REGISTRY, clear_provider_auth
from hermes_cli.credential_lifecycle import remove_provider_env_credential
slug = (params.get("slug") or "").strip()
if not slug:
return _err(rid, 4001, "slug is required")
pconfig = PROVIDER_REGISTRY.get(slug)
cleared_env = False
cleared_auth = False
# Remove API key env vars from .env and process, plus every mirror
# (env-seeded credential_pool entries, provider model cache rows,
# value-matched config.yaml api_key copies) via the unified helper —
# otherwise the provider resurrects in the picker after restart
# (#51071 / #59761).
if pconfig and pconfig.api_key_env_vars:
for ev in pconfig.api_key_env_vars:
if remove_provider_env_credential(ev).get("found"):
cleared_env = True
# Clear OAuth / credential pool state. This is a full provider
# disconnect (TUI "disconnect" action), so removing OAuth grants
# here is the documented intent — unlike the key-only delete paths.
cleared_auth = clear_provider_auth(slug)
if not cleared_env and not cleared_auth:
return _err(rid, 4005, f"no credentials found for {slug}")
provider_name = pconfig.name if pconfig else slug
return _ok(
rid,
{
"slug": slug,
"name": provider_name,
"disconnected": True,
},
)
except Exception as e:
return _err(rid, 5035, str(e))
def register(server) -> None:
"""Bind this module's handlers onto ``server``'s globals and registry."""
_registry.install(server)