mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat: new tui based on ink
This commit is contained in:
parent
624ad582a5
commit
2ea5345a7b
13 changed files with 4177 additions and 0 deletions
1170
docs/plans/2026-04-01-ink-gateway-tui-migration-plan.md
Normal file
1170
docs/plans/2026-04-01-ink-gateway-tui-migration-plan.md
Normal file
File diff suppressed because it is too large
Load diff
0
tui_gateway/__init__.py
Normal file
0
tui_gateway/__init__.py
Normal file
36
tui_gateway/entry.py
Normal file
36
tui_gateway/entry.py
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from tui_gateway.server import handle_request, resolve_skin
|
||||||
|
|
||||||
|
|
||||||
|
def _write(obj: dict):
|
||||||
|
sys.stdout.write(json.dumps(obj) + "\n")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
_write({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "event",
|
||||||
|
"params": {"type": "gateway.ready", "payload": {"skin": resolve_skin()}},
|
||||||
|
})
|
||||||
|
|
||||||
|
for raw in sys.stdin:
|
||||||
|
line = raw.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
req = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
_write({"jsonrpc": "2.0", "error": {"code": -32700, "message": "parse error"}, "id": None})
|
||||||
|
continue
|
||||||
|
|
||||||
|
resp = handle_request(req)
|
||||||
|
if resp is not None:
|
||||||
|
_write(resp)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
351
tui_gateway/server.py
Normal file
351
tui_gateway/server.py
Normal file
|
|
@ -0,0 +1,351 @@
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from hermes_constants import get_hermes_home
|
||||||
|
from hermes_cli.env_loader import load_hermes_dotenv
|
||||||
|
|
||||||
|
_hermes_home = get_hermes_home()
|
||||||
|
load_hermes_dotenv(hermes_home=_hermes_home, project_env=Path(__file__).parent.parent / ".env")
|
||||||
|
|
||||||
|
_sessions: dict[str, dict] = {}
|
||||||
|
_methods: dict[str, callable] = {}
|
||||||
|
_clarify_pending: dict[str, threading.Event] = {}
|
||||||
|
_clarify_answers: dict[str, str] = {}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Wire ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _emit(event_type: str, sid: str, payload: dict | None = None):
|
||||||
|
params = {"type": event_type, "session_id": sid}
|
||||||
|
if payload:
|
||||||
|
params["payload"] = payload
|
||||||
|
sys.stdout.write(json.dumps({"jsonrpc": "2.0", "method": "event", "params": params}) + "\n")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def _ok(req_id, result: dict) -> dict:
|
||||||
|
return {"jsonrpc": "2.0", "id": req_id, "result": result}
|
||||||
|
|
||||||
|
|
||||||
|
def _err(req_id, code: int, msg: str) -> dict:
|
||||||
|
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": msg}}
|
||||||
|
|
||||||
|
|
||||||
|
def method(name: str):
|
||||||
|
def dec(fn):
|
||||||
|
_methods[name] = fn
|
||||||
|
return fn
|
||||||
|
return dec
|
||||||
|
|
||||||
|
|
||||||
|
def handle_request(req: dict) -> dict | None:
|
||||||
|
fn = _methods.get(req.get("method", ""))
|
||||||
|
if not fn:
|
||||||
|
return _err(req.get("id"), -32601, f"unknown method: {req.get('method')}")
|
||||||
|
return fn(req.get("id"), req.get("params", {}))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def resolve_skin() -> dict:
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
from hermes_cli.skin_engine import init_skin_from_config, get_active_skin
|
||||||
|
cfg_path = _hermes_home / "config.yaml"
|
||||||
|
cfg = {}
|
||||||
|
if cfg_path.exists():
|
||||||
|
with open(cfg_path) as f:
|
||||||
|
cfg = yaml.safe_load(f) or {}
|
||||||
|
init_skin_from_config(cfg)
|
||||||
|
skin = get_active_skin()
|
||||||
|
return {"name": skin.name, "colors": skin.colors, "branding": skin.branding}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_model() -> str:
|
||||||
|
env = os.environ.get("HERMES_MODEL", "")
|
||||||
|
if env:
|
||||||
|
return env
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
cfg_path = _hermes_home / "config.yaml"
|
||||||
|
if cfg_path.exists():
|
||||||
|
with open(cfg_path) as f:
|
||||||
|
m = (yaml.safe_load(f) or {}).get("model", "")
|
||||||
|
if isinstance(m, dict):
|
||||||
|
return m.get("default", "")
|
||||||
|
if isinstance(m, str):
|
||||||
|
return m
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return "anthropic/claude-sonnet-4"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_usage(agent) -> dict:
|
||||||
|
ga = lambda k, fb=None: getattr(agent, k, 0) or (getattr(agent, fb, 0) if fb else 0)
|
||||||
|
return {
|
||||||
|
"input": ga("session_input_tokens", "session_prompt_tokens"),
|
||||||
|
"output": ga("session_output_tokens", "session_completion_tokens"),
|
||||||
|
"total": ga("session_total_tokens"),
|
||||||
|
"calls": ga("session_api_calls"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_session_info(agent) -> dict:
|
||||||
|
info: dict = {"model": getattr(agent, "model", ""), "tools": {}, "skills": {}}
|
||||||
|
try:
|
||||||
|
from model_tools import get_toolset_for_tool
|
||||||
|
for t in getattr(agent, "tools", []) or []:
|
||||||
|
name = t["function"]["name"]
|
||||||
|
info["tools"].setdefault(get_toolset_for_tool(name) or "other", []).append(name)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
from hermes_cli.banner import get_available_skills
|
||||||
|
info["skills"] = get_available_skills()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
def _make_clarify_cb(sid: str):
|
||||||
|
def cb(question: str, choices: list | None) -> str:
|
||||||
|
rid = uuid.uuid4().hex[:8]
|
||||||
|
ev = threading.Event()
|
||||||
|
_clarify_pending[rid] = ev
|
||||||
|
_emit("clarify.request", sid, {"request_id": rid, "question": question, "choices": choices})
|
||||||
|
ev.wait(timeout=300)
|
||||||
|
_clarify_pending.pop(rid, None)
|
||||||
|
return _clarify_answers.pop(rid, "")
|
||||||
|
return cb
|
||||||
|
|
||||||
|
|
||||||
|
def _register_approval_notify(sid: str, session_key: str):
|
||||||
|
try:
|
||||||
|
from tools.approval import register_gateway_notify
|
||||||
|
register_gateway_notify(session_key, lambda data: _emit("approval.request", sid, data))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ── Methods ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@method("session.create")
|
||||||
|
def _(req_id, params: dict) -> dict:
|
||||||
|
sid = uuid.uuid4().hex[:8]
|
||||||
|
session_key = f"tui-{sid}"
|
||||||
|
|
||||||
|
os.environ["HERMES_SESSION_KEY"] = session_key
|
||||||
|
os.environ["HERMES_INTERACTIVE"] = "1"
|
||||||
|
|
||||||
|
try:
|
||||||
|
from run_agent import AIAgent
|
||||||
|
agent = AIAgent(
|
||||||
|
model=_resolve_model(),
|
||||||
|
quiet_mode=True,
|
||||||
|
platform="tui",
|
||||||
|
tool_start_callback=lambda tc_id, name, args: _emit("tool.start", sid, {"tool_id": tc_id, "name": name}),
|
||||||
|
tool_complete_callback=lambda tc_id, name, args, result: _emit("tool.complete", sid, {"tool_id": tc_id, "name": name}),
|
||||||
|
tool_progress_callback=lambda name, preview, args: _emit("tool.progress", sid, {"name": name, "preview": preview}),
|
||||||
|
tool_gen_callback=lambda name: _emit("tool.generating", sid, {"name": name}),
|
||||||
|
thinking_callback=lambda text: _emit("thinking.delta", sid, {"text": text}),
|
||||||
|
reasoning_callback=lambda text: _emit("reasoning.delta", sid, {"text": text}),
|
||||||
|
status_callback=lambda text: _emit("status.update", sid, {"text": text}),
|
||||||
|
clarify_callback=_make_clarify_cb(sid),
|
||||||
|
)
|
||||||
|
_sessions[sid] = {"agent": agent, "session_key": session_key}
|
||||||
|
except Exception as e:
|
||||||
|
return _err(req_id, 5000, f"agent init failed: {e}")
|
||||||
|
|
||||||
|
_register_approval_notify(sid, session_key)
|
||||||
|
|
||||||
|
from tools.approval import load_permanent_allowlist
|
||||||
|
load_permanent_allowlist()
|
||||||
|
|
||||||
|
_emit("session.info", sid, _collect_session_info(agent))
|
||||||
|
return _ok(req_id, {"session_id": sid})
|
||||||
|
|
||||||
|
|
||||||
|
@method("prompt.submit")
|
||||||
|
def _(req_id, params: dict) -> dict:
|
||||||
|
sid, text = params.get("session_id", ""), params.get("text", "")
|
||||||
|
session = _sessions.get(sid)
|
||||||
|
if not session:
|
||||||
|
return _err(req_id, 4001, "session not found")
|
||||||
|
|
||||||
|
agent = session["agent"]
|
||||||
|
_emit("message.start", sid)
|
||||||
|
|
||||||
|
def run():
|
||||||
|
try:
|
||||||
|
result = agent.run_conversation(
|
||||||
|
text,
|
||||||
|
stream_callback=lambda delta: _emit("message.delta", sid, {"text": delta}),
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(result, dict):
|
||||||
|
final = result.get("final_response", "")
|
||||||
|
status = "interrupted" if result.get("interrupted") else "error" if result.get("error") else "complete"
|
||||||
|
_emit("message.complete", sid, {
|
||||||
|
"text": final or "",
|
||||||
|
"usage": _get_usage(agent),
|
||||||
|
"status": status,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
_emit("message.complete", sid, {"text": str(result), "usage": _get_usage(agent), "status": "complete"})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_emit("error", sid, {"message": str(e)})
|
||||||
|
|
||||||
|
threading.Thread(target=run, daemon=True).start()
|
||||||
|
return _ok(req_id, {"status": "streaming"})
|
||||||
|
|
||||||
|
|
||||||
|
@method("clarify.respond")
|
||||||
|
def _(req_id, params: dict) -> dict:
|
||||||
|
rid = params.get("request_id", "")
|
||||||
|
ev = _clarify_pending.get(rid)
|
||||||
|
if not ev:
|
||||||
|
return _err(req_id, 4003, "no pending clarify request")
|
||||||
|
_clarify_answers[rid] = params.get("answer", "")
|
||||||
|
ev.set()
|
||||||
|
return _ok(req_id, {"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
|
@method("approval.respond")
|
||||||
|
def _(req_id, params: dict) -> dict:
|
||||||
|
sid = params.get("session_id", "")
|
||||||
|
choice = params.get("choice", "deny")
|
||||||
|
|
||||||
|
session = _sessions.get(sid)
|
||||||
|
if not session:
|
||||||
|
return _err(req_id, 4001, "session not found")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from tools.approval import resolve_gateway_approval
|
||||||
|
n = resolve_gateway_approval(session["session_key"], choice, resolve_all=params.get("all", False))
|
||||||
|
return _ok(req_id, {"resolved": n})
|
||||||
|
except Exception as e:
|
||||||
|
return _err(req_id, 5004, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@method("session.usage")
|
||||||
|
def _(req_id, params: dict) -> dict:
|
||||||
|
session = _sessions.get(params.get("session_id", ""))
|
||||||
|
if not session:
|
||||||
|
return _err(req_id, 4001, "session not found")
|
||||||
|
return _ok(req_id, _get_usage(session["agent"]))
|
||||||
|
|
||||||
|
|
||||||
|
@method("session.history")
|
||||||
|
def _(req_id, params: dict) -> dict:
|
||||||
|
session = _sessions.get(params.get("session_id", ""))
|
||||||
|
if not session:
|
||||||
|
return _err(req_id, 4001, "session not found")
|
||||||
|
history = getattr(session["agent"], "conversation_history", [])
|
||||||
|
return _ok(req_id, {"count": len(history)})
|
||||||
|
|
||||||
|
|
||||||
|
@method("session.undo")
|
||||||
|
def _(req_id, params: dict) -> dict:
|
||||||
|
session = _sessions.get(params.get("session_id", ""))
|
||||||
|
if not session:
|
||||||
|
return _err(req_id, 4001, "session not found")
|
||||||
|
history = getattr(session["agent"], "conversation_history", [])
|
||||||
|
removed = 0
|
||||||
|
while history and history[-1].get("role") in ("assistant", "tool"):
|
||||||
|
history.pop(); removed += 1
|
||||||
|
if history and history[-1].get("role") == "user":
|
||||||
|
history.pop(); removed += 1
|
||||||
|
return _ok(req_id, {"removed": removed})
|
||||||
|
|
||||||
|
|
||||||
|
@method("session.compress")
|
||||||
|
def _(req_id, params: dict) -> dict:
|
||||||
|
session = _sessions.get(params.get("session_id", ""))
|
||||||
|
if not session:
|
||||||
|
return _err(req_id, 4001, "session not found")
|
||||||
|
agent = session["agent"]
|
||||||
|
try:
|
||||||
|
if hasattr(agent, "compress_context"):
|
||||||
|
agent.compress_context()
|
||||||
|
return _ok(req_id, {"status": "compressed", "usage": _get_usage(agent)})
|
||||||
|
except Exception as e:
|
||||||
|
return _err(req_id, 5005, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@method("config.set")
|
||||||
|
def _(req_id, params: dict) -> dict:
|
||||||
|
key, value = params.get("key", ""), params.get("value", "")
|
||||||
|
|
||||||
|
if key == "model":
|
||||||
|
os.environ["HERMES_MODEL"] = value
|
||||||
|
return _ok(req_id, {"key": key, "value": value})
|
||||||
|
|
||||||
|
if key == "skin":
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
cfg_path = _hermes_home / "config.yaml"
|
||||||
|
cfg = {}
|
||||||
|
if cfg_path.exists():
|
||||||
|
with open(cfg_path) as f:
|
||||||
|
cfg = yaml.safe_load(f) or {}
|
||||||
|
cfg["skin"] = value
|
||||||
|
with open(cfg_path, "w") as f:
|
||||||
|
yaml.safe_dump(cfg, f)
|
||||||
|
return _ok(req_id, {"key": key, "value": value})
|
||||||
|
except Exception as e:
|
||||||
|
return _err(req_id, 5001, str(e))
|
||||||
|
|
||||||
|
return _err(req_id, 4002, f"unknown config key: {key}")
|
||||||
|
|
||||||
|
|
||||||
|
@method("session.interrupt")
|
||||||
|
def _(req_id, params: dict) -> dict:
|
||||||
|
session = _sessions.get(params.get("session_id", ""))
|
||||||
|
if not session:
|
||||||
|
return _err(req_id, 4001, "session not found")
|
||||||
|
|
||||||
|
if hasattr(session["agent"], "interrupt"):
|
||||||
|
session["agent"].interrupt()
|
||||||
|
|
||||||
|
for rid, ev in list(_clarify_pending.items()):
|
||||||
|
_clarify_answers[rid] = ""
|
||||||
|
ev.set()
|
||||||
|
|
||||||
|
try:
|
||||||
|
from tools.approval import resolve_gateway_approval
|
||||||
|
resolve_gateway_approval(session["session_key"], "deny", resolve_all=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return _ok(req_id, {"status": "interrupted"})
|
||||||
|
|
||||||
|
|
||||||
|
@method("shell.exec")
|
||||||
|
def _(req_id, params: dict) -> dict:
|
||||||
|
cmd = params.get("command", "")
|
||||||
|
if not cmd:
|
||||||
|
return _err(req_id, 4004, "empty command")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from tools.approval import detect_dangerous_command
|
||||||
|
is_dangerous, _, description = detect_dangerous_command(cmd)
|
||||||
|
if is_dangerous:
|
||||||
|
return _err(req_id, 4005, f"blocked: {description}. Use the agent for dangerous commands (it has approval flow).")
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=30, cwd=os.getcwd())
|
||||||
|
return _ok(req_id, {"stdout": r.stdout[-4000:], "stderr": r.stderr[-2000:], "code": r.returncode})
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return _err(req_id, 5002, "command timed out (30s)")
|
||||||
|
except Exception as e:
|
||||||
|
return _err(req_id, 5003, str(e))
|
||||||
1213
ui-tui/package-lock.json
generated
Normal file
1213
ui-tui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
23
ui-tui/package.json
Normal file
23
ui-tui/package.json
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"name": "hermes-tui",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx --watch src/main.tsx",
|
||||||
|
"start": "tsx src/main.tsx",
|
||||||
|
"build": "tsc",
|
||||||
|
"test": "echo 'no tests yet'"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"ink": "^6.8.0",
|
||||||
|
"ink-text-input": "^6.0.0",
|
||||||
|
"react": "^19.2.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^25.5.0",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"tsx": "^4.19.0",
|
||||||
|
"typescript": "^5.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
29
ui-tui/src/altScreen.tsx
Normal file
29
ui-tui/src/altScreen.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { useEffect, type PropsWithChildren } from 'react'
|
||||||
|
import { Box, useStdout } from 'ink'
|
||||||
|
|
||||||
|
const ENTER = '\x1b[?1049h\x1b[2J\x1b[H'
|
||||||
|
const LEAVE = '\x1b[?1049l'
|
||||||
|
|
||||||
|
export function AltScreen({ children }: PropsWithChildren) {
|
||||||
|
const { stdout } = useStdout()
|
||||||
|
const rows = stdout?.rows ?? 24
|
||||||
|
const cols = stdout?.columns ?? 80
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
process.stdout.write(ENTER)
|
||||||
|
|
||||||
|
const leave = () => process.stdout.write(LEAVE)
|
||||||
|
process.on('exit', leave)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
leave()
|
||||||
|
process.off('exit', leave)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" height={rows} width={cols} overflow="hidden">
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
43
ui-tui/src/banner.ts
Normal file
43
ui-tui/src/banner.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import type { ThemeColors } from './theme.js'
|
||||||
|
|
||||||
|
type Line = [string, string]
|
||||||
|
|
||||||
|
const LOGO_ART = [
|
||||||
|
'██╗ ██╗███████╗██████╗ ███╗ ███╗███████╗███████╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗',
|
||||||
|
'██║ ██║██╔════╝██╔══██╗████╗ ████║██╔════╝██╔════╝ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝',
|
||||||
|
'███████║█████╗ ██████╔╝██╔████╔██║█████╗ ███████╗█████╗███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║ ',
|
||||||
|
'██╔══██║██╔══╝ ██╔══██╗██║╚██╔╝██║██╔══╝ ╚════██║╚════╝██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║ ',
|
||||||
|
'██║ ██║███████╗██║ ██║██║ ╚═╝ ██║███████╗███████║ ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║ ',
|
||||||
|
'╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝ ',
|
||||||
|
]
|
||||||
|
|
||||||
|
const CADUCEUS_ART = [
|
||||||
|
'⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⡀⠀⣀⣀⠀⢀⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
|
||||||
|
'⠀⠀⠀⠀⠀⠀⢀⣠⣴⣾⣿⣿⣇⠸⣿⣿⠇⣸⣿⣿⣷⣦⣄⡀⠀⠀⠀⠀⠀⠀',
|
||||||
|
'⠀⢀⣠⣴⣶⠿⠋⣩⡿⣿⡿⠻⣿⡇⢠⡄⢸⣿⠟⢿⣿⢿⣍⠙⠿⣶⣦⣄⡀⠀',
|
||||||
|
'⠀⠀⠉⠉⠁⠶⠟⠋⠀⠉⠀⢀⣈⣁⡈⢁⣈⣁⡀⠀⠉⠀⠙⠻⠶⠈⠉⠉⠀⠀',
|
||||||
|
'⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣴⣿⡿⠛⢁⡈⠛⢿⣿⣦⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
|
||||||
|
'⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠿⣿⣦⣤⣈⠁⢠⣴⣿⠿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
|
||||||
|
'⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠉⠻⢿⣿⣦⡉⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
|
||||||
|
'⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⢷⣦⣈⠛⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
|
||||||
|
'⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⣴⠦⠈⠙⠿⣦⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
|
||||||
|
'⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠸⣿⣤⡈⠁⢤⣿⠇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
|
||||||
|
'⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠛⠷⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
|
||||||
|
'⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⠑⢶⣄⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
|
||||||
|
'⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⠁⢰⡆⠈⡿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
|
||||||
|
'⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠳⠈⣡⠞⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
|
||||||
|
'⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
|
||||||
|
]
|
||||||
|
|
||||||
|
const LOGO_GRADIENT = [0, 0, 1, 1, 2, 2] as const
|
||||||
|
const CADUC_GRADIENT = [2, 2, 1, 1, 0, 0, 1, 1, 2, 2, 3, 3, 3, 3, 3] as const
|
||||||
|
|
||||||
|
function colorize(art: string[], gradient: readonly number[], c: ThemeColors): Line[] {
|
||||||
|
const palette = [c.gold, c.amber, c.bronze, c.dim]
|
||||||
|
return art.map((text, i) => [palette[gradient[i]] ?? c.dim, text])
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LOGO_WIDTH = 98
|
||||||
|
|
||||||
|
export const logo = (c: ThemeColors) => colorize(LOGO_ART, LOGO_GRADIENT, c)
|
||||||
|
export const caduceus = (c: ThemeColors) => colorize(CADUCEUS_ART, CADUC_GRADIENT, c)
|
||||||
72
ui-tui/src/gatewayClient.ts
Normal file
72
ui-tui/src/gatewayClient.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { spawn, type ChildProcess } from 'node:child_process'
|
||||||
|
import { createInterface } from 'node:readline'
|
||||||
|
import { EventEmitter } from 'node:events'
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
|
||||||
|
export interface GatewayEvent {
|
||||||
|
type: string
|
||||||
|
session_id?: string
|
||||||
|
payload?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Pending {
|
||||||
|
resolve: (v: unknown) => void
|
||||||
|
reject: (e: Error) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GatewayClient extends EventEmitter {
|
||||||
|
private proc: ChildProcess | null = null
|
||||||
|
private reqId = 0
|
||||||
|
private pending = new Map<string, Pending>()
|
||||||
|
|
||||||
|
start() {
|
||||||
|
const root = resolve(import.meta.dirname, '../../')
|
||||||
|
|
||||||
|
this.proc = spawn(
|
||||||
|
process.env.HERMES_PYTHON ?? resolve(root, 'venv/bin/python'),
|
||||||
|
['-m', 'tui_gateway.entry'],
|
||||||
|
{ cwd: root, stdio: ['pipe', 'pipe', 'inherit'] },
|
||||||
|
)
|
||||||
|
|
||||||
|
createInterface({ input: this.proc.stdout! }).on('line', (raw) => {
|
||||||
|
try { this.dispatch(JSON.parse(raw)) } catch {}
|
||||||
|
})
|
||||||
|
|
||||||
|
this.proc.on('exit', (code) => this.emit('exit', code))
|
||||||
|
}
|
||||||
|
|
||||||
|
private dispatch(msg: Record<string, unknown>) {
|
||||||
|
const id = msg.id as string | undefined
|
||||||
|
const p = id ? this.pending.get(id) : undefined
|
||||||
|
|
||||||
|
if (p) {
|
||||||
|
this.pending.delete(id!)
|
||||||
|
msg.error
|
||||||
|
? p.reject(new Error((msg.error as any).message))
|
||||||
|
: p.resolve(msg.result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.method === 'event')
|
||||||
|
this.emit('event', msg.params as GatewayEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
request(method: string, params: Record<string, unknown> = {}): Promise<unknown> {
|
||||||
|
const id = `r${++this.reqId}`
|
||||||
|
|
||||||
|
this.proc!.stdin!.write(
|
||||||
|
JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n',
|
||||||
|
)
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.pending.set(id, { resolve, reject })
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.pending.delete(id))
|
||||||
|
reject(new Error(`timeout: ${method}`))
|
||||||
|
}, 30_000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
kill() { this.proc?.kill() }
|
||||||
|
}
|
||||||
46
ui-tui/src/main.js
Normal file
46
ui-tui/src/main.js
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
"use strict";
|
||||||
|
var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) {
|
||||||
|
if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) {
|
||||||
|
if (ar || !(i in from)) {
|
||||||
|
if (!ar) ar = Array.prototype.slice.call(from, 0, i);
|
||||||
|
ar[i] = from[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return to.concat(ar || Array.prototype.slice.call(from));
|
||||||
|
};
|
||||||
|
var _a;
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
var react_1 = require("react");
|
||||||
|
var ink_1 = require("ink");
|
||||||
|
var ink_text_input_1 = require("ink-text-input");
|
||||||
|
function App() {
|
||||||
|
var _a = (0, react_1.useState)(''), input = _a[0], setInput = _a[1];
|
||||||
|
var _b = (0, react_1.useState)([]), messages = _b[0], setMessages = _b[1];
|
||||||
|
var handleSubmit = function (value) {
|
||||||
|
if (!value.trim())
|
||||||
|
return;
|
||||||
|
setMessages(function (prev) { return __spreadArray(__spreadArray([], prev, true), ["> ".concat(value), "[echo] ".concat(value)], false); });
|
||||||
|
setInput('');
|
||||||
|
};
|
||||||
|
return (<ink_1.Box flexDirection="column" padding={1}>
|
||||||
|
<ink_1.Box marginBottom={1}>
|
||||||
|
<ink_1.Text bold color="yellow">hermes</ink_1.Text>
|
||||||
|
<ink_1.Text dimColor> (ink proof-of-concept)</ink_1.Text>
|
||||||
|
</ink_1.Box>
|
||||||
|
|
||||||
|
<ink_1.Box flexDirection="column" marginBottom={1}>
|
||||||
|
{messages.map(function (msg, i) { return (<ink_1.Text key={i}>{msg}</ink_1.Text>); })}
|
||||||
|
</ink_1.Box>
|
||||||
|
|
||||||
|
<ink_1.Box>
|
||||||
|
<ink_1.Text bold color="cyan">{'> '}</ink_1.Text>
|
||||||
|
<ink_text_input_1.default value={input} onChange={setInput} onSubmit={handleSubmit}/>
|
||||||
|
</ink_1.Box>
|
||||||
|
</ink_1.Box>);
|
||||||
|
}
|
||||||
|
var isTTY = (_a = process.stdin.isTTY) !== null && _a !== void 0 ? _a : false;
|
||||||
|
if (!isTTY) {
|
||||||
|
console.log('hermes-tui: ink loaded, no TTY attached (run in a real terminal)');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
(0, ink_1.render)(<App />);
|
||||||
1073
ui-tui/src/main.tsx
Normal file
1073
ui-tui/src/main.tsx
Normal file
File diff suppressed because it is too large
Load diff
105
ui-tui/src/theme.ts
Normal file
105
ui-tui/src/theme.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
export interface ThemeColors {
|
||||||
|
gold: string
|
||||||
|
amber: string
|
||||||
|
bronze: string
|
||||||
|
cornsilk: string
|
||||||
|
dim: string
|
||||||
|
|
||||||
|
label: string
|
||||||
|
ok: string
|
||||||
|
error: string
|
||||||
|
warn: string
|
||||||
|
|
||||||
|
statusBg: string
|
||||||
|
statusFg: string
|
||||||
|
statusGood: string
|
||||||
|
statusWarn: string
|
||||||
|
statusBad: string
|
||||||
|
statusCritical: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThemeBrand {
|
||||||
|
name: string
|
||||||
|
icon: string
|
||||||
|
prompt: string
|
||||||
|
welcome: string
|
||||||
|
goodbye: string
|
||||||
|
tool: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Theme {
|
||||||
|
color: ThemeColors
|
||||||
|
brand: ThemeBrand
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const DEFAULT_THEME: Theme = {
|
||||||
|
color: {
|
||||||
|
gold: '#FFD700',
|
||||||
|
amber: '#FFBF00',
|
||||||
|
bronze: '#CD7F32',
|
||||||
|
cornsilk: '#FFF8DC',
|
||||||
|
dim: '#B8860B',
|
||||||
|
|
||||||
|
label: '#4dd0e1',
|
||||||
|
ok: '#4caf50',
|
||||||
|
error: '#ef5350',
|
||||||
|
warn: '#ffa726',
|
||||||
|
|
||||||
|
statusBg: '#1a1a2e',
|
||||||
|
statusFg: '#C0C0C0',
|
||||||
|
statusGood: '#8FBC8F',
|
||||||
|
statusWarn: '#FFD700',
|
||||||
|
statusBad: '#FF8C00',
|
||||||
|
statusCritical: '#FF6B6B',
|
||||||
|
},
|
||||||
|
|
||||||
|
brand: {
|
||||||
|
name: 'Hermes Agent',
|
||||||
|
icon: '⚕',
|
||||||
|
prompt: '❯',
|
||||||
|
welcome: 'Type your message or /help for commands.',
|
||||||
|
goodbye: 'Goodbye! ⚕',
|
||||||
|
tool: '┊',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function fromSkin(
|
||||||
|
colors: Record<string, string>,
|
||||||
|
branding: Record<string, string>,
|
||||||
|
): Theme {
|
||||||
|
const d = DEFAULT_THEME
|
||||||
|
const c = (k: string) => colors[k]
|
||||||
|
|
||||||
|
return {
|
||||||
|
color: {
|
||||||
|
gold: c('banner_title') ?? d.color.gold,
|
||||||
|
amber: c('banner_accent') ?? d.color.amber,
|
||||||
|
bronze: c('banner_border') ?? d.color.bronze,
|
||||||
|
cornsilk: c('banner_text') ?? d.color.cornsilk,
|
||||||
|
dim: c('banner_dim') ?? d.color.dim,
|
||||||
|
|
||||||
|
label: c('ui_label') ?? d.color.label,
|
||||||
|
ok: c('ui_ok') ?? d.color.ok,
|
||||||
|
error: c('ui_error') ?? d.color.error,
|
||||||
|
warn: c('ui_warn') ?? d.color.warn,
|
||||||
|
|
||||||
|
statusBg: d.color.statusBg,
|
||||||
|
statusFg: d.color.statusFg,
|
||||||
|
statusGood: c('ui_ok') ?? d.color.statusGood,
|
||||||
|
statusWarn: c('ui_warn') ?? d.color.statusWarn,
|
||||||
|
statusBad: d.color.statusBad,
|
||||||
|
statusCritical: d.color.statusCritical,
|
||||||
|
},
|
||||||
|
|
||||||
|
brand: {
|
||||||
|
name: branding.agent_name ?? d.brand.name,
|
||||||
|
icon: d.brand.icon,
|
||||||
|
prompt: branding.prompt_symbol ?? d.brand.prompt,
|
||||||
|
welcome: branding.welcome ?? d.brand.welcome,
|
||||||
|
goodbye: branding.goodbye ?? d.brand.goodbye,
|
||||||
|
tool: d.brand.tool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
16
ui-tui/tsconfig.json
Normal file
16
ui-tui/tsconfig.json
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"types": ["node"],
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue