mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-29 15:31:38 +08:00
Compare commits
5 Commits
fix/plugin
...
feat/dashb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cd2b280fd | ||
|
|
2c2e32cc45 | ||
|
|
a0701b1d5a | ||
|
|
3d21aee811 | ||
|
|
29b337bca7 |
11
AGENTS.md
11
AGENTS.md
@@ -243,6 +243,17 @@ npm run fmt # prettier
|
|||||||
npm test # vitest
|
npm test # vitest
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### TUI in the Dashboard (`hermes dashboard` → `/chat`)
|
||||||
|
|
||||||
|
The dashboard embeds the real `hermes --tui` — **not** a rewrite. See `hermes_cli/pty_bridge.py` + the `@app.websocket("/api/pty")` endpoint in `hermes_cli/web_server.py`.
|
||||||
|
|
||||||
|
- Browser loads `web/src/pages/ChatPage.tsx`, which mounts xterm.js's `Terminal` with the WebGL renderer, `@xterm/addon-fit` for container-driven resize, and `@xterm/addon-unicode11` for modern wide-character widths.
|
||||||
|
- `/api/pty?token=…` upgrades to a WebSocket; auth uses the same ephemeral `_SESSION_TOKEN` as REST, via query param (browsers can't set `Authorization` on WS upgrade).
|
||||||
|
- The server spawns whatever `hermes --tui` would spawn, through `ptyprocess` (POSIX PTY — WSL works, native Windows does not).
|
||||||
|
- Frames: raw PTY bytes each direction; resize via `\x1b[RESIZE:<cols>;<rows>]` intercepted on the server and applied with `TIOCSWINSZ`.
|
||||||
|
|
||||||
|
**Never add a parallel chat surface in React.** If you catch yourself re-implementing slash popover / model picker / tool cards for the dashboard, stop — the TUI already does those, and anything new you add to Ink will appear in the dashboard automatically.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Adding New Tools
|
## Adding New Tools
|
||||||
|
|||||||
221
hermes_cli/pty_bridge.py
Normal file
221
hermes_cli/pty_bridge.py
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
"""PTY bridge for `hermes dashboard` chat tab.
|
||||||
|
|
||||||
|
Wraps a child process behind a pseudo-terminal so its ANSI output can be
|
||||||
|
streamed to a browser-side terminal emulator (xterm.js) and typed
|
||||||
|
keystrokes can be fed back in. The only caller today is the
|
||||||
|
``/api/pty`` WebSocket endpoint in ``hermes_cli.web_server``.
|
||||||
|
|
||||||
|
Design constraints:
|
||||||
|
|
||||||
|
* **POSIX-only.** Hermes Agent supports Windows exclusively via WSL, which
|
||||||
|
exposes a native POSIX PTY via ``openpty(3)``. Native Windows Python
|
||||||
|
has no PTY; :class:`PtyUnavailableError` is raised with a user-readable
|
||||||
|
install/platform message so the dashboard can render a banner instead of
|
||||||
|
crashing.
|
||||||
|
* **Zero Node dependency on the server side.** We use :mod:`ptyprocess`,
|
||||||
|
which is a pure-Python wrapper around the OS calls. The browser talks
|
||||||
|
to the same ``hermes --tui`` binary it would launch from the CLI, so
|
||||||
|
every TUI feature (slash popover, model picker, tool rows, markdown,
|
||||||
|
skin engine, clarify/sudo/approval prompts) ships automatically.
|
||||||
|
* **Byte-safe I/O.** Reads and writes go through the PTY master fd
|
||||||
|
directly — we avoid :class:`ptyprocess.PtyProcessUnicode` because
|
||||||
|
streaming ANSI is inherently byte-oriented and UTF-8 boundaries may land
|
||||||
|
mid-read.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import errno
|
||||||
|
import fcntl
|
||||||
|
import os
|
||||||
|
import select
|
||||||
|
import signal
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
import termios
|
||||||
|
import time
|
||||||
|
from typing import Optional, Sequence
|
||||||
|
|
||||||
|
try:
|
||||||
|
import ptyprocess # type: ignore
|
||||||
|
_PTY_AVAILABLE = not sys.platform.startswith("win")
|
||||||
|
except ImportError: # pragma: no cover - dev env without ptyprocess
|
||||||
|
ptyprocess = None # type: ignore
|
||||||
|
_PTY_AVAILABLE = False
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["PtyBridge", "PtyUnavailableError"]
|
||||||
|
|
||||||
|
|
||||||
|
class PtyUnavailableError(RuntimeError):
|
||||||
|
"""Raised when a PTY cannot be created on this platform.
|
||||||
|
|
||||||
|
Today this means native Windows (no ConPTY bindings) or a dev
|
||||||
|
environment missing the ``ptyprocess`` dependency. The dashboard
|
||||||
|
surfaces the message to the user as a chat-tab banner.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class PtyBridge:
|
||||||
|
"""Thin wrapper around ``ptyprocess.PtyProcess`` for byte streaming.
|
||||||
|
|
||||||
|
Not thread-safe. A single bridge is owned by the WebSocket handler
|
||||||
|
that spawned it; the reader runs in an executor thread while writes
|
||||||
|
happen on the event-loop thread. Both sides are OK because the
|
||||||
|
kernel PTY is the actual synchronization point — we never call
|
||||||
|
:mod:`ptyprocess` methods concurrently, we only call ``os.read`` and
|
||||||
|
``os.write`` on the master fd, which is safe.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, proc: "ptyprocess.PtyProcess"): # type: ignore[name-defined]
|
||||||
|
self._proc = proc
|
||||||
|
self._fd: int = proc.fd
|
||||||
|
self._closed = False
|
||||||
|
|
||||||
|
# -- lifecycle --------------------------------------------------------
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_available(cls) -> bool:
|
||||||
|
"""True if a PTY can be spawned on this platform."""
|
||||||
|
return bool(_PTY_AVAILABLE)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def spawn(
|
||||||
|
cls,
|
||||||
|
argv: Sequence[str],
|
||||||
|
*,
|
||||||
|
cwd: Optional[str] = None,
|
||||||
|
env: Optional[dict] = None,
|
||||||
|
cols: int = 80,
|
||||||
|
rows: int = 24,
|
||||||
|
) -> "PtyBridge":
|
||||||
|
"""Spawn ``argv`` behind a new PTY and return a bridge.
|
||||||
|
|
||||||
|
Raises :class:`PtyUnavailableError` if the platform can't host a
|
||||||
|
PTY. Raises :class:`FileNotFoundError` or :class:`OSError` for
|
||||||
|
ordinary exec failures (missing binary, bad cwd, etc.).
|
||||||
|
"""
|
||||||
|
if not _PTY_AVAILABLE:
|
||||||
|
raise PtyUnavailableError(
|
||||||
|
"Pseudo-terminals are unavailable on this platform. "
|
||||||
|
"Hermes Agent supports Windows only via WSL."
|
||||||
|
)
|
||||||
|
# Let caller-supplied env fully override inheritance; if they pass
|
||||||
|
# None we inherit the server's env (same semantics as subprocess).
|
||||||
|
spawn_env = os.environ.copy() if env is None else env
|
||||||
|
proc = ptyprocess.PtyProcess.spawn( # type: ignore[union-attr]
|
||||||
|
list(argv),
|
||||||
|
cwd=cwd,
|
||||||
|
env=spawn_env,
|
||||||
|
dimensions=(rows, cols),
|
||||||
|
)
|
||||||
|
return cls(proc)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pid(self) -> int:
|
||||||
|
return int(self._proc.pid)
|
||||||
|
|
||||||
|
def is_alive(self) -> bool:
|
||||||
|
if self._closed:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return bool(self._proc.isalive())
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# -- I/O --------------------------------------------------------------
|
||||||
|
|
||||||
|
def read(self, timeout: float = 0.2) -> Optional[bytes]:
|
||||||
|
"""Read up to 64 KiB of raw bytes from the PTY master.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
* bytes — zero or more bytes of child output
|
||||||
|
* empty bytes (``b""``) — no data available within ``timeout``
|
||||||
|
* None — child has exited and the master fd is at EOF
|
||||||
|
|
||||||
|
Never blocks longer than ``timeout`` seconds. Safe to call after
|
||||||
|
:meth:`close`; returns ``None`` in that case.
|
||||||
|
"""
|
||||||
|
if self._closed:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
readable, _, _ = select.select([self._fd], [], [], timeout)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
if not readable:
|
||||||
|
return b""
|
||||||
|
try:
|
||||||
|
data = os.read(self._fd, 65536)
|
||||||
|
except OSError as exc:
|
||||||
|
# EIO on Linux = slave side closed. EBADF = already closed.
|
||||||
|
if exc.errno in (errno.EIO, errno.EBADF):
|
||||||
|
return None
|
||||||
|
raise
|
||||||
|
if not data:
|
||||||
|
return None
|
||||||
|
return data
|
||||||
|
|
||||||
|
def write(self, data: bytes) -> None:
|
||||||
|
"""Write raw bytes to the PTY master (i.e. the child's stdin)."""
|
||||||
|
if self._closed or not data:
|
||||||
|
return
|
||||||
|
# os.write can return a short write under load; loop until drained.
|
||||||
|
view = memoryview(data)
|
||||||
|
while view:
|
||||||
|
try:
|
||||||
|
n = os.write(self._fd, view)
|
||||||
|
except OSError as exc:
|
||||||
|
if exc.errno in (errno.EIO, errno.EBADF, errno.EPIPE):
|
||||||
|
return
|
||||||
|
raise
|
||||||
|
if n <= 0:
|
||||||
|
return
|
||||||
|
view = view[n:]
|
||||||
|
|
||||||
|
def resize(self, cols: int, rows: int) -> None:
|
||||||
|
"""Forward a terminal resize to the child via ``TIOCSWINSZ``."""
|
||||||
|
if self._closed:
|
||||||
|
return
|
||||||
|
# struct winsize: rows, cols, xpixel, ypixel (all unsigned short)
|
||||||
|
winsize = struct.pack("HHHH", max(1, rows), max(1, cols), 0, 0)
|
||||||
|
try:
|
||||||
|
fcntl.ioctl(self._fd, termios.TIOCSWINSZ, winsize)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# -- teardown ---------------------------------------------------------
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Terminate the child (SIGTERM → 0.5s grace → SIGKILL) and close fds.
|
||||||
|
|
||||||
|
Idempotent. Reaping the child is important so we don't leak
|
||||||
|
zombies across the lifetime of the dashboard process.
|
||||||
|
"""
|
||||||
|
if self._closed:
|
||||||
|
return
|
||||||
|
self._closed = True
|
||||||
|
|
||||||
|
# SIGHUP is the conventional "your terminal went away" signal.
|
||||||
|
# We escalate if the child ignores it.
|
||||||
|
for sig in (signal.SIGHUP, signal.SIGTERM, signal.SIGKILL):
|
||||||
|
if not self._proc.isalive():
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
self._proc.kill(sig)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
deadline = time.monotonic() + 0.5
|
||||||
|
while self._proc.isalive() and time.monotonic() < deadline:
|
||||||
|
time.sleep(0.02)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._proc.close(force=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Context-manager sugar — handy in tests and ad-hoc scripts.
|
||||||
|
def __enter__(self) -> "PtyBridge":
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_exc) -> None:
|
||||||
|
self.close()
|
||||||
@@ -49,7 +49,7 @@ from hermes_cli.config import (
|
|||||||
from gateway.status import get_running_pid, read_runtime_status
|
from gateway.status import get_running_pid, read_runtime_status
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from fastapi import FastAPI, HTTPException, Request
|
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
@@ -2242,6 +2242,148 @@ async def get_usage_analytics(days: int = 30):
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /api/pty — PTY-over-WebSocket bridge for the dashboard "Chat" tab.
|
||||||
|
#
|
||||||
|
# The endpoint spawns the same ``hermes --tui`` binary the CLI uses, behind
|
||||||
|
# a POSIX pseudo-terminal, and forwards bytes + resize escapes across a
|
||||||
|
# WebSocket. The browser renders the ANSI through xterm.js (see
|
||||||
|
# web/src/pages/ChatPage.tsx).
|
||||||
|
#
|
||||||
|
# Auth: ``?token=<session_token>`` query param (browsers can't set
|
||||||
|
# Authorization on the WS upgrade). Same ephemeral ``_SESSION_TOKEN`` as
|
||||||
|
# REST. Localhost-only — we defensively reject non-loopback clients even
|
||||||
|
# though uvicorn binds to 127.0.0.1.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import re
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from hermes_cli.pty_bridge import PtyBridge, PtyUnavailableError
|
||||||
|
|
||||||
|
_RESIZE_RE = re.compile(rb"\x1b\[RESIZE:(\d+);(\d+)\]")
|
||||||
|
_PTY_READ_CHUNK_TIMEOUT = 0.2
|
||||||
|
# Starlette's TestClient reports the peer as "testclient"; treat it as
|
||||||
|
# loopback so tests don't need to rewrite request scope.
|
||||||
|
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost", "testclient"})
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_chat_argv(
|
||||||
|
resume: Optional[str] = None,
|
||||||
|
) -> tuple[list[str], Optional[str], Optional[dict]]:
|
||||||
|
"""Resolve the argv + cwd + env for the chat PTY.
|
||||||
|
|
||||||
|
Default: whatever ``hermes --tui`` would run. Tests monkeypatch this
|
||||||
|
function to inject a tiny fake command (``cat``, ``sh -c 'printf …'``)
|
||||||
|
so nothing has to build Node or the TUI bundle.
|
||||||
|
|
||||||
|
Session resume is propagated via the ``HERMES_TUI_RESUME`` env var —
|
||||||
|
matching what ``hermes_cli.main._launch_tui`` does for the CLI path.
|
||||||
|
Appending ``--resume <id>`` to argv doesn't work because ``ui-tui`` does
|
||||||
|
not parse its argv.
|
||||||
|
"""
|
||||||
|
from hermes_cli.main import PROJECT_ROOT, _make_tui_argv
|
||||||
|
|
||||||
|
argv, cwd = _make_tui_argv(PROJECT_ROOT / "ui-tui", tui_dev=False)
|
||||||
|
env: Optional[dict] = None
|
||||||
|
if resume:
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["HERMES_TUI_RESUME"] = resume
|
||||||
|
return list(argv), str(cwd) if cwd else None, env
|
||||||
|
|
||||||
|
|
||||||
|
@app.websocket("/api/pty")
|
||||||
|
async def pty_ws(ws: WebSocket) -> None:
|
||||||
|
# --- auth + loopback check (before accept so we can close cleanly) ---
|
||||||
|
token = ws.query_params.get("token", "")
|
||||||
|
expected = _SESSION_TOKEN
|
||||||
|
if not hmac.compare_digest(token.encode(), expected.encode()):
|
||||||
|
await ws.close(code=4401)
|
||||||
|
return
|
||||||
|
|
||||||
|
client_host = ws.client.host if ws.client else ""
|
||||||
|
if client_host and client_host not in _LOOPBACK_HOSTS:
|
||||||
|
await ws.close(code=4403)
|
||||||
|
return
|
||||||
|
|
||||||
|
await ws.accept()
|
||||||
|
|
||||||
|
# --- spawn PTY ------------------------------------------------------
|
||||||
|
resume = ws.query_params.get("resume") or None
|
||||||
|
try:
|
||||||
|
argv, cwd, env = _resolve_chat_argv(resume=resume)
|
||||||
|
except SystemExit as exc:
|
||||||
|
# _make_tui_argv calls sys.exit(1) when node/npm is missing.
|
||||||
|
await ws.send_text(f"\r\n\x1b[31mChat unavailable: {exc}\x1b[0m\r\n")
|
||||||
|
await ws.close(code=1011)
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
bridge = PtyBridge.spawn(argv, cwd=cwd, env=env)
|
||||||
|
except PtyUnavailableError as exc:
|
||||||
|
await ws.send_text(f"\r\n\x1b[31mChat unavailable: {exc}\x1b[0m\r\n")
|
||||||
|
await ws.close(code=1011)
|
||||||
|
return
|
||||||
|
except (FileNotFoundError, OSError) as exc:
|
||||||
|
await ws.send_text(f"\r\n\x1b[31mChat failed to start: {exc}\x1b[0m\r\n")
|
||||||
|
await ws.close(code=1011)
|
||||||
|
return
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
# --- reader task: PTY master → WebSocket ----------------------------
|
||||||
|
async def pump_pty_to_ws() -> None:
|
||||||
|
while True:
|
||||||
|
chunk = await loop.run_in_executor(
|
||||||
|
None, bridge.read, _PTY_READ_CHUNK_TIMEOUT
|
||||||
|
)
|
||||||
|
if chunk is None: # EOF
|
||||||
|
return
|
||||||
|
if not chunk: # no data this tick; yield control and retry
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
await ws.send_bytes(chunk)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
reader_task = asyncio.create_task(pump_pty_to_ws())
|
||||||
|
|
||||||
|
# --- writer loop: WebSocket → PTY master ----------------------------
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
msg = await ws.receive()
|
||||||
|
msg_type = msg.get("type")
|
||||||
|
if msg_type == "websocket.disconnect":
|
||||||
|
break
|
||||||
|
raw = msg.get("bytes")
|
||||||
|
if raw is None:
|
||||||
|
text = msg.get("text")
|
||||||
|
raw = text.encode("utf-8") if isinstance(text, str) else b""
|
||||||
|
if not raw:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Resize escape is consumed locally, never written to the PTY.
|
||||||
|
match = _RESIZE_RE.match(raw)
|
||||||
|
if match and match.end() == len(raw):
|
||||||
|
cols = int(match.group(1))
|
||||||
|
rows = int(match.group(2))
|
||||||
|
bridge.resize(cols=cols, rows=rows)
|
||||||
|
continue
|
||||||
|
|
||||||
|
bridge.write(raw)
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
reader_task.cancel()
|
||||||
|
try:
|
||||||
|
await reader_task
|
||||||
|
except (asyncio.CancelledError, Exception):
|
||||||
|
pass
|
||||||
|
bridge.close()
|
||||||
|
|
||||||
|
|
||||||
def mount_spa(application: FastAPI):
|
def mount_spa(application: FastAPI):
|
||||||
"""Mount the built SPA. Falls back to index.html for client-side routing.
|
"""Mount the built SPA. Falls back to index.html for client-side routing.
|
||||||
|
|
||||||
|
|||||||
172
tests/hermes_cli/test_pty_bridge.py
Normal file
172
tests/hermes_cli/test_pty_bridge.py
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
"""Unit tests for hermes_cli.pty_bridge — PTY spawning + byte forwarding.
|
||||||
|
|
||||||
|
These tests drive the bridge with minimal POSIX processes (echo, env, sleep,
|
||||||
|
printf) to verify it behaves like a PTY you can read/write/resize/close.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytest.importorskip("ptyprocess", reason="ptyprocess not installed")
|
||||||
|
|
||||||
|
from hermes_cli.pty_bridge import PtyBridge, PtyUnavailableError
|
||||||
|
|
||||||
|
|
||||||
|
skip_on_windows = pytest.mark.skipif(
|
||||||
|
sys.platform.startswith("win"), reason="PTY bridge is POSIX-only"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_until(bridge: PtyBridge, needle: bytes, timeout: float = 5.0) -> bytes:
|
||||||
|
"""Accumulate PTY output until we see `needle` or time out."""
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
buf = bytearray()
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
chunk = bridge.read(timeout=0.2)
|
||||||
|
if chunk is None:
|
||||||
|
break
|
||||||
|
buf.extend(chunk)
|
||||||
|
if needle in buf:
|
||||||
|
return bytes(buf)
|
||||||
|
return bytes(buf)
|
||||||
|
|
||||||
|
|
||||||
|
@skip_on_windows
|
||||||
|
class TestPtyBridgeSpawn:
|
||||||
|
def test_is_available_on_posix(self):
|
||||||
|
assert PtyBridge.is_available() is True
|
||||||
|
|
||||||
|
def test_spawn_returns_bridge_with_pid(self):
|
||||||
|
bridge = PtyBridge.spawn(["true"])
|
||||||
|
try:
|
||||||
|
assert bridge.pid > 0
|
||||||
|
finally:
|
||||||
|
bridge.close()
|
||||||
|
|
||||||
|
def test_spawn_raises_on_missing_argv0(self, tmp_path):
|
||||||
|
with pytest.raises((FileNotFoundError, OSError)):
|
||||||
|
PtyBridge.spawn([str(tmp_path / "definitely-not-a-real-binary")])
|
||||||
|
|
||||||
|
|
||||||
|
@skip_on_windows
|
||||||
|
class TestPtyBridgeIO:
|
||||||
|
def test_reads_child_stdout(self):
|
||||||
|
bridge = PtyBridge.spawn(["/bin/sh", "-c", "printf hermes-ok"])
|
||||||
|
try:
|
||||||
|
output = _read_until(bridge, b"hermes-ok")
|
||||||
|
assert b"hermes-ok" in output
|
||||||
|
finally:
|
||||||
|
bridge.close()
|
||||||
|
|
||||||
|
def test_write_sends_to_child_stdin(self):
|
||||||
|
# `cat` with no args echoes stdin back to stdout. We write a line,
|
||||||
|
# read it back, then signal EOF to let cat exit cleanly.
|
||||||
|
bridge = PtyBridge.spawn(["/bin/cat"])
|
||||||
|
try:
|
||||||
|
bridge.write(b"hello-pty\n")
|
||||||
|
output = _read_until(bridge, b"hello-pty")
|
||||||
|
assert b"hello-pty" in output
|
||||||
|
finally:
|
||||||
|
bridge.close()
|
||||||
|
|
||||||
|
def test_read_returns_none_after_child_exits(self):
|
||||||
|
bridge = PtyBridge.spawn(["/bin/sh", "-c", "printf done"])
|
||||||
|
try:
|
||||||
|
_read_until(bridge, b"done")
|
||||||
|
# Give the child a beat to exit cleanly, then drain until EOF.
|
||||||
|
deadline = time.monotonic() + 3.0
|
||||||
|
while bridge.is_alive() and time.monotonic() < deadline:
|
||||||
|
bridge.read(timeout=0.1)
|
||||||
|
# Next reads after exit should return None (EOF), not raise.
|
||||||
|
got_none = False
|
||||||
|
for _ in range(10):
|
||||||
|
if bridge.read(timeout=0.1) is None:
|
||||||
|
got_none = True
|
||||||
|
break
|
||||||
|
assert got_none, "PtyBridge.read did not return None after child EOF"
|
||||||
|
finally:
|
||||||
|
bridge.close()
|
||||||
|
|
||||||
|
|
||||||
|
@skip_on_windows
|
||||||
|
class TestPtyBridgeResize:
|
||||||
|
def test_resize_updates_child_winsize(self):
|
||||||
|
# tput reads COLUMNS/LINES from the TTY ioctl (TIOCGWINSZ).
|
||||||
|
# Spawn a shell, resize, then ask tput for the dimensions.
|
||||||
|
bridge = PtyBridge.spawn(
|
||||||
|
["/bin/sh", "-c", "sleep 0.1; tput cols; tput lines"],
|
||||||
|
cols=80,
|
||||||
|
rows=24,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
bridge.resize(cols=123, rows=45)
|
||||||
|
output = _read_until(bridge, b"45", timeout=5.0)
|
||||||
|
# tput prints just the numbers, one per line
|
||||||
|
assert b"123" in output
|
||||||
|
assert b"45" in output
|
||||||
|
finally:
|
||||||
|
bridge.close()
|
||||||
|
|
||||||
|
|
||||||
|
@skip_on_windows
|
||||||
|
class TestPtyBridgeClose:
|
||||||
|
def test_close_is_idempotent(self):
|
||||||
|
bridge = PtyBridge.spawn(["/bin/sh", "-c", "sleep 30"])
|
||||||
|
bridge.close()
|
||||||
|
bridge.close() # must not raise
|
||||||
|
assert not bridge.is_alive()
|
||||||
|
|
||||||
|
def test_close_terminates_long_running_child(self):
|
||||||
|
bridge = PtyBridge.spawn(["/bin/sh", "-c", "sleep 30"])
|
||||||
|
pid = bridge.pid
|
||||||
|
bridge.close()
|
||||||
|
# Give the kernel a moment to reap
|
||||||
|
deadline = time.monotonic() + 3.0
|
||||||
|
reaped = False
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
time.sleep(0.05)
|
||||||
|
except ProcessLookupError:
|
||||||
|
reaped = True
|
||||||
|
break
|
||||||
|
assert reaped, f"pid {pid} still running after close()"
|
||||||
|
|
||||||
|
|
||||||
|
@skip_on_windows
|
||||||
|
class TestPtyBridgeEnv:
|
||||||
|
def test_cwd_is_respected(self, tmp_path):
|
||||||
|
bridge = PtyBridge.spawn(
|
||||||
|
["/bin/sh", "-c", "pwd"],
|
||||||
|
cwd=str(tmp_path),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
output = _read_until(bridge, str(tmp_path).encode())
|
||||||
|
assert str(tmp_path).encode() in output
|
||||||
|
finally:
|
||||||
|
bridge.close()
|
||||||
|
|
||||||
|
def test_env_is_forwarded(self):
|
||||||
|
bridge = PtyBridge.spawn(
|
||||||
|
["/bin/sh", "-c", "printf %s \"$HERMES_PTY_TEST\""],
|
||||||
|
env={**os.environ, "HERMES_PTY_TEST": "pty-env-works"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
output = _read_until(bridge, b"pty-env-works")
|
||||||
|
assert b"pty-env-works" in output
|
||||||
|
finally:
|
||||||
|
bridge.close()
|
||||||
|
|
||||||
|
|
||||||
|
class TestPtyBridgeUnavailable:
|
||||||
|
"""Platform fallback semantics — PtyUnavailableError is importable and
|
||||||
|
carries a user-readable message."""
|
||||||
|
|
||||||
|
def test_error_carries_user_message(self):
|
||||||
|
err = PtyUnavailableError("platform not supported")
|
||||||
|
assert "platform" in str(err)
|
||||||
@@ -1256,3 +1256,186 @@ class TestStatusRemoteGateway:
|
|||||||
assert data["gateway_running"] is True
|
assert data["gateway_running"] is True
|
||||||
assert data["gateway_pid"] is None
|
assert data["gateway_pid"] is None
|
||||||
assert data["gateway_state"] == "running"
|
assert data["gateway_state"] == "running"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /api/pty WebSocket — terminal bridge for the dashboard "Chat" tab.
|
||||||
|
#
|
||||||
|
# These tests drive the endpoint with a tiny fake command (typically ``cat``
|
||||||
|
# or ``sh -c 'printf …'``) instead of the real ``hermes --tui`` binary. The
|
||||||
|
# endpoint resolves its argv through ``_resolve_chat_argv``, so tests
|
||||||
|
# monkeypatch that hook.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
skip_on_windows = pytest.mark.skipif(
|
||||||
|
sys.platform.startswith("win"), reason="PTY bridge is POSIX-only"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@skip_on_windows
|
||||||
|
class TestPtyWebSocket:
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _setup(self, monkeypatch, _isolate_hermes_home):
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
import hermes_cli.web_server as ws
|
||||||
|
|
||||||
|
# Avoid exec'ing the actual TUI in tests: every test below installs
|
||||||
|
# its own fake argv via ``ws._resolve_chat_argv``.
|
||||||
|
self.ws_module = ws
|
||||||
|
self.token = ws._SESSION_TOKEN
|
||||||
|
self.client = TestClient(ws.app)
|
||||||
|
|
||||||
|
def _url(self, token: str | None = None, **params: str) -> str:
|
||||||
|
tok = token if token is not None else self.token
|
||||||
|
# TestClient.websocket_connect takes the path; it reconstructs the
|
||||||
|
# query string, so we pass it inline.
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
q = {"token": tok, **params}
|
||||||
|
return f"/api/pty?{urlencode(q)}"
|
||||||
|
|
||||||
|
def test_rejects_missing_token(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
self.ws_module,
|
||||||
|
"_resolve_chat_argv",
|
||||||
|
lambda resume=None: (["/bin/cat"], None, None),
|
||||||
|
)
|
||||||
|
from starlette.websockets import WebSocketDisconnect
|
||||||
|
|
||||||
|
with pytest.raises(WebSocketDisconnect) as exc:
|
||||||
|
with self.client.websocket_connect("/api/pty"):
|
||||||
|
pass
|
||||||
|
assert exc.value.code == 4401
|
||||||
|
|
||||||
|
def test_rejects_bad_token(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
self.ws_module,
|
||||||
|
"_resolve_chat_argv",
|
||||||
|
lambda resume=None: (["/bin/cat"], None, None),
|
||||||
|
)
|
||||||
|
from starlette.websockets import WebSocketDisconnect
|
||||||
|
|
||||||
|
with pytest.raises(WebSocketDisconnect) as exc:
|
||||||
|
with self.client.websocket_connect(self._url(token="wrong")):
|
||||||
|
pass
|
||||||
|
assert exc.value.code == 4401
|
||||||
|
|
||||||
|
def test_streams_child_stdout_to_client(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
self.ws_module,
|
||||||
|
"_resolve_chat_argv",
|
||||||
|
lambda resume=None: (
|
||||||
|
["/bin/sh", "-c", "printf hermes-ws-ok"],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with self.client.websocket_connect(self._url()) as conn:
|
||||||
|
# Drain frames until we see the needle or time out. TestClient's
|
||||||
|
# recv_bytes blocks; loop until we have the signal byte string.
|
||||||
|
buf = b""
|
||||||
|
import time
|
||||||
|
|
||||||
|
deadline = time.monotonic() + 5.0
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
try:
|
||||||
|
frame = conn.receive_bytes()
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
if frame:
|
||||||
|
buf += frame
|
||||||
|
if b"hermes-ws-ok" in buf:
|
||||||
|
break
|
||||||
|
assert b"hermes-ws-ok" in buf
|
||||||
|
|
||||||
|
def test_client_input_reaches_child_stdin(self, monkeypatch):
|
||||||
|
# ``cat`` echoes stdin back, so a write → read round-trip proves
|
||||||
|
# the full duplex path.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
self.ws_module,
|
||||||
|
"_resolve_chat_argv",
|
||||||
|
lambda resume=None: (["/bin/cat"], None, None),
|
||||||
|
)
|
||||||
|
with self.client.websocket_connect(self._url()) as conn:
|
||||||
|
conn.send_bytes(b"round-trip-payload\n")
|
||||||
|
buf = b""
|
||||||
|
import time
|
||||||
|
|
||||||
|
deadline = time.monotonic() + 5.0
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
frame = conn.receive_bytes()
|
||||||
|
if frame:
|
||||||
|
buf += frame
|
||||||
|
if b"round-trip-payload" in buf:
|
||||||
|
break
|
||||||
|
assert b"round-trip-payload" in buf
|
||||||
|
|
||||||
|
def test_resize_escape_is_forwarded(self, monkeypatch):
|
||||||
|
# Resize escape gets intercepted and applied via TIOCSWINSZ,
|
||||||
|
# then ``tput cols/lines`` reports the new dimensions back.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
self.ws_module,
|
||||||
|
"_resolve_chat_argv",
|
||||||
|
# sleep gives the test time to push the resize before tput runs
|
||||||
|
lambda resume=None: (
|
||||||
|
["/bin/sh", "-c", "sleep 0.15; tput cols; tput lines"],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with self.client.websocket_connect(self._url()) as conn:
|
||||||
|
conn.send_text("\x1b[RESIZE:99;41]")
|
||||||
|
buf = b""
|
||||||
|
import time
|
||||||
|
|
||||||
|
deadline = time.monotonic() + 5.0
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
frame = conn.receive_bytes()
|
||||||
|
if frame:
|
||||||
|
buf += frame
|
||||||
|
if b"99" in buf and b"41" in buf:
|
||||||
|
break
|
||||||
|
assert b"99" in buf and b"41" in buf
|
||||||
|
|
||||||
|
def test_unavailable_platform_closes_with_message(self, monkeypatch):
|
||||||
|
from hermes_cli.pty_bridge import PtyUnavailableError
|
||||||
|
|
||||||
|
def _raise(argv, **kwargs):
|
||||||
|
raise PtyUnavailableError("pty missing for tests")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
self.ws_module,
|
||||||
|
"_resolve_chat_argv",
|
||||||
|
lambda resume=None: (["/bin/cat"], None, None),
|
||||||
|
)
|
||||||
|
# Patch PtyBridge.spawn at the web_server module's binding.
|
||||||
|
import hermes_cli.web_server as ws_mod
|
||||||
|
|
||||||
|
monkeypatch.setattr(ws_mod.PtyBridge, "spawn", classmethod(lambda cls, *a, **k: _raise(*a, **k)))
|
||||||
|
|
||||||
|
with self.client.websocket_connect(self._url()) as conn:
|
||||||
|
# Expect a final text frame with the error message, then close.
|
||||||
|
msg = conn.receive_text()
|
||||||
|
assert "pty missing" in msg or "unavailable" in msg.lower() or "pty" in msg.lower()
|
||||||
|
|
||||||
|
def test_resume_parameter_is_forwarded_to_argv(self, monkeypatch):
|
||||||
|
captured: dict = {}
|
||||||
|
|
||||||
|
def fake_resolve(resume=None):
|
||||||
|
captured["resume"] = resume
|
||||||
|
return (["/bin/sh", "-c", "printf resume-arg-ok"], None, None)
|
||||||
|
|
||||||
|
monkeypatch.setattr(self.ws_module, "_resolve_chat_argv", fake_resolve)
|
||||||
|
|
||||||
|
with self.client.websocket_connect(self._url(resume="sess-42")) as conn:
|
||||||
|
# Drain briefly so the handler actually invokes the resolver.
|
||||||
|
try:
|
||||||
|
conn.receive_bytes()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
assert captured.get("resume") == "sess-42"
|
||||||
|
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ export const coreCommands: SlashCommand[] = [
|
|||||||
}
|
}
|
||||||
|
|
||||||
writeOsc52Clipboard(target.text)
|
writeOsc52Clipboard(target.text)
|
||||||
sys('sent OSC52 copy sequence (terminal support required)')
|
sys(`copied ${target.text.length} chars`)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
82
web/package-lock.json
generated
82
web/package-lock.json
generated
@@ -12,6 +12,11 @@
|
|||||||
"@observablehq/plot": "^0.6.17",
|
"@observablehq/plot": "^0.6.17",
|
||||||
"@react-three/fiber": "^9.6.0",
|
"@react-three/fiber": "^9.6.0",
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/addon-unicode11": "^0.9.0",
|
||||||
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
|
"@xterm/addon-webgl": "^0.19.0",
|
||||||
|
"@xterm/xterm": "^6.0.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"gsap": "^3.15.0",
|
"gsap": "^3.15.0",
|
||||||
@@ -39,6 +44,50 @@
|
|||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"../../../../../wterm/packages/@wterm/core": {
|
||||||
|
"version": "0.1.9",
|
||||||
|
"extraneous": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@internal/ts": "workspace:*",
|
||||||
|
"typescript": "^6.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"../../../../../wterm/packages/@wterm/dom": {
|
||||||
|
"version": "0.1.9",
|
||||||
|
"extraneous": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@wterm/core": "workspace:*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@internal/ts": "workspace:*",
|
||||||
|
"jsdom": "^29.0.2",
|
||||||
|
"typescript": "^6.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"../../../../../wterm/packages/@wterm/react": {
|
||||||
|
"version": "0.1.9",
|
||||||
|
"extraneous": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@internal/ts": "workspace:*",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@wterm/dom": "workspace:*",
|
||||||
|
"jsdom": "^29.0.2",
|
||||||
|
"react": "^19.2.5",
|
||||||
|
"react-dom": "^19.2.5",
|
||||||
|
"typescript": "^6.0.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@wterm/dom": "workspace:*",
|
||||||
|
"react": "^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||||
@@ -2861,6 +2910,39 @@
|
|||||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
|
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@xterm/addon-fit": {
|
||||||
|
"version": "0.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
|
||||||
|
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@xterm/addon-unicode11": {
|
||||||
|
"version": "0.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0.tgz",
|
||||||
|
"integrity": "sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@xterm/addon-web-links": {
|
||||||
|
"version": "0.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
|
||||||
|
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@xterm/addon-webgl": {
|
||||||
|
"version": "0.19.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz",
|
||||||
|
"integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@xterm/xterm": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"addons/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.16.0",
|
"version": "8.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||||
|
|||||||
@@ -17,6 +17,11 @@
|
|||||||
"@observablehq/plot": "^0.6.17",
|
"@observablehq/plot": "^0.6.17",
|
||||||
"@react-three/fiber": "^9.6.0",
|
"@react-three/fiber": "^9.6.0",
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/addon-unicode11": "^0.9.0",
|
||||||
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
|
"@xterm/addon-webgl": "^0.19.0",
|
||||||
|
"@xterm/xterm": "^6.0.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"gsap": "^3.15.0",
|
"gsap": "^3.15.0",
|
||||||
|
|||||||
BIN
web/public/fonts-terminal/JetBrainsMono-Bold.woff2
Normal file
BIN
web/public/fonts-terminal/JetBrainsMono-Bold.woff2
Normal file
Binary file not shown.
BIN
web/public/fonts-terminal/JetBrainsMono-Italic.woff2
Normal file
BIN
web/public/fonts-terminal/JetBrainsMono-Italic.woff2
Normal file
Binary file not shown.
BIN
web/public/fonts-terminal/JetBrainsMono-Regular.woff2
Normal file
BIN
web/public/fonts-terminal/JetBrainsMono-Regular.woff2
Normal file
Binary file not shown.
@@ -33,6 +33,7 @@ import LogsPage from "@/pages/LogsPage";
|
|||||||
import AnalyticsPage from "@/pages/AnalyticsPage";
|
import AnalyticsPage from "@/pages/AnalyticsPage";
|
||||||
import CronPage from "@/pages/CronPage";
|
import CronPage from "@/pages/CronPage";
|
||||||
import SkillsPage from "@/pages/SkillsPage";
|
import SkillsPage from "@/pages/SkillsPage";
|
||||||
|
import ChatPage from "@/pages/ChatPage";
|
||||||
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
||||||
import { ThemeSwitcher } from "@/components/ThemeSwitcher";
|
import { ThemeSwitcher } from "@/components/ThemeSwitcher";
|
||||||
import { useI18n } from "@/i18n";
|
import { useI18n } from "@/i18n";
|
||||||
@@ -41,6 +42,7 @@ import type { RegisteredPlugin } from "@/plugins";
|
|||||||
|
|
||||||
const BUILTIN_NAV: NavItem[] = [
|
const BUILTIN_NAV: NavItem[] = [
|
||||||
{ path: "/", labelKey: "status", label: "Status", icon: Activity },
|
{ path: "/", labelKey: "status", label: "Status", icon: Activity },
|
||||||
|
{ path: "/chat", labelKey: "chat", label: "Chat", icon: Terminal },
|
||||||
{
|
{
|
||||||
path: "/sessions",
|
path: "/sessions",
|
||||||
labelKey: "sessions",
|
labelKey: "sessions",
|
||||||
@@ -230,6 +232,7 @@ export default function App() {
|
|||||||
<main className="relative z-2 mx-auto w-full max-w-[1600px] flex-1 px-3 sm:px-6 pt-16 sm:pt-20 pb-4 sm:pb-8">
|
<main className="relative z-2 mx-auto w-full max-w-[1600px] flex-1 px-3 sm:px-6 pt-16 sm:pt-20 pb-4 sm:pb-8">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<StatusPage />} />
|
<Route path="/" element={<StatusPage />} />
|
||||||
|
<Route path="/chat" element={<ChatPage />} />
|
||||||
<Route path="/sessions" element={<SessionsPage />} />
|
<Route path="/sessions" element={<SessionsPage />} />
|
||||||
<Route path="/analytics" element={<AnalyticsPage />} />
|
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||||
<Route path="/logs" element={<LogsPage />} />
|
<Route path="/logs" element={<LogsPage />} />
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export const en: Translations = {
|
|||||||
},
|
},
|
||||||
nav: {
|
nav: {
|
||||||
status: "Status",
|
status: "Status",
|
||||||
|
chat: "Chat",
|
||||||
sessions: "Sessions",
|
sessions: "Sessions",
|
||||||
analytics: "Analytics",
|
analytics: "Analytics",
|
||||||
logs: "Logs",
|
logs: "Logs",
|
||||||
@@ -106,6 +107,7 @@ export const en: Translations = {
|
|||||||
noMessages: "No messages",
|
noMessages: "No messages",
|
||||||
untitledSession: "Untitled session",
|
untitledSession: "Untitled session",
|
||||||
deleteSession: "Delete session",
|
deleteSession: "Delete session",
|
||||||
|
resumeInChat: "Resume in Chat",
|
||||||
previousPage: "Previous page",
|
previousPage: "Previous page",
|
||||||
nextPage: "Next page",
|
nextPage: "Next page",
|
||||||
roles: {
|
roles: {
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export interface Translations {
|
|||||||
};
|
};
|
||||||
nav: {
|
nav: {
|
||||||
status: string;
|
status: string;
|
||||||
|
chat: string;
|
||||||
sessions: string;
|
sessions: string;
|
||||||
analytics: string;
|
analytics: string;
|
||||||
logs: string;
|
logs: string;
|
||||||
@@ -110,6 +111,7 @@ export interface Translations {
|
|||||||
noMessages: string;
|
noMessages: string;
|
||||||
untitledSession: string;
|
untitledSession: string;
|
||||||
deleteSession: string;
|
deleteSession: string;
|
||||||
|
resumeInChat: string;
|
||||||
previousPage: string;
|
previousPage: string;
|
||||||
nextPage: string;
|
nextPage: string;
|
||||||
roles: {
|
roles: {
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export const zh: Translations = {
|
|||||||
},
|
},
|
||||||
nav: {
|
nav: {
|
||||||
status: "状态",
|
status: "状态",
|
||||||
|
chat: "对话",
|
||||||
sessions: "会话",
|
sessions: "会话",
|
||||||
analytics: "分析",
|
analytics: "分析",
|
||||||
logs: "日志",
|
logs: "日志",
|
||||||
@@ -106,6 +107,7 @@ export const zh: Translations = {
|
|||||||
noMessages: "暂无消息",
|
noMessages: "暂无消息",
|
||||||
untitledSession: "无标题会话",
|
untitledSession: "无标题会话",
|
||||||
deleteSession: "删除会话",
|
deleteSession: "删除会话",
|
||||||
|
resumeInChat: "在对话中继续",
|
||||||
previousPage: "上一页",
|
previousPage: "上一页",
|
||||||
nextPage: "下一页",
|
nextPage: "下一页",
|
||||||
roles: {
|
roles: {
|
||||||
|
|||||||
@@ -5,6 +5,36 @@
|
|||||||
Tailwind's JIT purge. */
|
Tailwind's JIT purge. */
|
||||||
@source '../node_modules/@nous-research/ui/dist';
|
@source '../node_modules/@nous-research/ui/dist';
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* JetBrains Mono — bundled for the embedded TUI (/chat tab). */
|
||||||
|
/* Gives the terminal a proper monospace font even on systems where */
|
||||||
|
/* the user doesn't have one installed locally; xterm.js picks it up */
|
||||||
|
/* via ChatPage's `fontFamily` option. */
|
||||||
|
/* Apache-2.0. */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/fonts-terminal/JetBrainsMono-Regular.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/fonts-terminal/JetBrainsMono-Bold.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/fonts-terminal/JetBrainsMono-Italic.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Hermes Agent — Nous DS with the LENS_0 (Hermes teal) lens applied */
|
/* Hermes Agent — Nous DS with the LENS_0 (Hermes teal) lens applied */
|
||||||
/* statically. Mirrors nousnet-web/(hermes-agent)/layout.tsx so the */
|
/* statically. Mirrors nousnet-web/(hermes-agent)/layout.tsx so the */
|
||||||
|
|||||||
444
web/src/pages/ChatPage.tsx
Normal file
444
web/src/pages/ChatPage.tsx
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
/**
|
||||||
|
* ChatPage — embeds `hermes --tui` inside the dashboard.
|
||||||
|
*
|
||||||
|
* <div host> (dashboard chrome) .
|
||||||
|
* └─ <div wrapper> (rounded, dark bg, padded — the "terminal window" .
|
||||||
|
* look that gives the page a distinct visual identity) .
|
||||||
|
* └─ @xterm/xterm Terminal (WebGL renderer, Unicode 11 widths) .
|
||||||
|
* │ onData keystrokes → WebSocket → PTY master .
|
||||||
|
* │ onResize terminal resize → `\x1b[RESIZE:cols;rows]` .
|
||||||
|
* │ write(data) PTY output bytes → VT100 parser .
|
||||||
|
* ▼ .
|
||||||
|
* WebSocket /api/pty?token=<session> .
|
||||||
|
* ▼ .
|
||||||
|
* FastAPI pty_ws (hermes_cli/web_server.py) .
|
||||||
|
* ▼ .
|
||||||
|
* POSIX PTY → `node ui-tui/dist/entry.js` → tui_gateway + AIAgent .
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
import { Terminal } from "@xterm/xterm";
|
||||||
|
import { FitAddon } from "@xterm/addon-fit";
|
||||||
|
import { WebglAddon } from "@xterm/addon-webgl";
|
||||||
|
import { Unicode11Addon } from "@xterm/addon-unicode11";
|
||||||
|
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||||
|
import { Copy } from "lucide-react";
|
||||||
|
import "@xterm/xterm/css/xterm.css";
|
||||||
|
|
||||||
|
function buildWsUrl(token: string, resume: string | null): string {
|
||||||
|
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
const qs = new URLSearchParams({ token });
|
||||||
|
if (resume) qs.set("resume", resume);
|
||||||
|
return `${proto}//${window.location.host}/api/pty?${qs.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Colors for the terminal body. Matches the dashboard's dark teal canvas
|
||||||
|
// with cream foreground — we intentionally don't pick monokai or a loud
|
||||||
|
// theme, because the TUI's skin engine already paints the content; the
|
||||||
|
// terminal chrome just needs to sit quietly inside the dashboard.
|
||||||
|
const TERMINAL_THEME = {
|
||||||
|
background: "#0d2626",
|
||||||
|
foreground: "#f0e6d2",
|
||||||
|
cursor: "#f0e6d2",
|
||||||
|
cursorAccent: "#0d2626",
|
||||||
|
selectionBackground: "#f0e6d244",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ChatPage() {
|
||||||
|
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const termRef = useRef<Terminal | null>(null);
|
||||||
|
const fitRef = useRef<FitAddon | null>(null);
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const [banner, setBanner] = useState<string | null>(null);
|
||||||
|
const [copyState, setCopyState] = useState<"idle" | "copied">("idle");
|
||||||
|
const copyResetRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const resumeRef = useRef<string | null>(searchParams.get("resume"));
|
||||||
|
|
||||||
|
const handleCopyLast = () => {
|
||||||
|
const ws = wsRef.current;
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
// Send the slash as a burst, wait long enough for Ink's tokenizer to
|
||||||
|
// emit a keypress event for each character (not coalesce them into a
|
||||||
|
// paste), then send Return as its own event. The timing here is
|
||||||
|
// empirical — 100ms is safely past Node's default stdin coalescing
|
||||||
|
// window and well inside UI responsiveness.
|
||||||
|
ws.send("/copy");
|
||||||
|
setTimeout(() => {
|
||||||
|
const s = wsRef.current;
|
||||||
|
if (s && s.readyState === WebSocket.OPEN) s.send("\r");
|
||||||
|
}, 100);
|
||||||
|
setCopyState("copied");
|
||||||
|
if (copyResetRef.current) clearTimeout(copyResetRef.current);
|
||||||
|
copyResetRef.current = setTimeout(() => setCopyState("idle"), 1500);
|
||||||
|
termRef.current?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const host = hostRef.current;
|
||||||
|
if (!host) return;
|
||||||
|
|
||||||
|
const token = window.__HERMES_SESSION_TOKEN__;
|
||||||
|
if (!token) {
|
||||||
|
setBanner(
|
||||||
|
"Session token unavailable. Open this page through `hermes dashboard`, not directly.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const term = new Terminal({
|
||||||
|
allowProposedApi: true,
|
||||||
|
cursorBlink: true,
|
||||||
|
fontFamily:
|
||||||
|
"'JetBrains Mono', 'Cascadia Mono', 'Fira Code', 'MesloLGS NF', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace",
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
macOptionIsMeta: true,
|
||||||
|
scrollback: 0,
|
||||||
|
theme: TERMINAL_THEME,
|
||||||
|
});
|
||||||
|
termRef.current = term;
|
||||||
|
|
||||||
|
// --- Clipboard integration ---------------------------------------
|
||||||
|
//
|
||||||
|
// Three independent paths all route to the system clipboard:
|
||||||
|
//
|
||||||
|
// 1. **Selection → Ctrl+C (or Cmd+C on macOS).** Ink's own handler
|
||||||
|
// in useInputHandlers.ts turns Ctrl+C into a copy when the
|
||||||
|
// terminal has a selection, then emits an OSC 52 escape. Our
|
||||||
|
// OSC 52 handler below decodes that escape and writes to the
|
||||||
|
// browser clipboard — so the flow works just like it does in
|
||||||
|
// `hermes --tui`.
|
||||||
|
//
|
||||||
|
// 2. **Ctrl/Cmd+Shift+C.** Belt-and-suspenders shortcut that
|
||||||
|
// operates directly on xterm's selection, useful if the TUI
|
||||||
|
// ever stops listening (e.g. overlays / pickers) or if the user
|
||||||
|
// has selected with the mouse outside of Ink's selection model.
|
||||||
|
//
|
||||||
|
// 3. **Ctrl/Cmd+Shift+V.** Reads the system clipboard and feeds
|
||||||
|
// it to the terminal as keyboard input. xterm's paste() wraps
|
||||||
|
// it with bracketed-paste if the host has that mode enabled.
|
||||||
|
//
|
||||||
|
// OSC 52 reads (terminal asking to read the clipboard) are not
|
||||||
|
// supported — that would let any content the TUI renders exfiltrate
|
||||||
|
// the user's clipboard.
|
||||||
|
term.parser.registerOscHandler(52, (data) => {
|
||||||
|
// Format: "<targets>;<base64 | '?'>"
|
||||||
|
const semi = data.indexOf(";");
|
||||||
|
if (semi < 0) return false;
|
||||||
|
const payload = data.slice(semi + 1);
|
||||||
|
if (payload === "?" || payload === "") return false; // read/clear — ignore
|
||||||
|
try {
|
||||||
|
// atob returns a binary string (one byte per char); we need UTF-8
|
||||||
|
// decode so multi-byte codepoints (≥, →, emoji, CJK) round-trip
|
||||||
|
// correctly. Without this step, the three UTF-8 bytes of `≥`
|
||||||
|
// would land in the clipboard as the three separate Latin-1
|
||||||
|
// characters `≥`.
|
||||||
|
const binary = atob(payload);
|
||||||
|
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
|
||||||
|
const text = new TextDecoder("utf-8").decode(bytes);
|
||||||
|
navigator.clipboard.writeText(text).catch(() => {});
|
||||||
|
} catch {
|
||||||
|
// Malformed base64 — silently drop.
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isMac =
|
||||||
|
typeof navigator !== "undefined" && /Mac/i.test(navigator.platform);
|
||||||
|
|
||||||
|
term.attachCustomKeyEventHandler((ev) => {
|
||||||
|
if (ev.type !== "keydown") return true;
|
||||||
|
|
||||||
|
const copyModifier = isMac ? ev.metaKey : ev.ctrlKey && ev.shiftKey;
|
||||||
|
const pasteModifier = isMac ? ev.metaKey : ev.ctrlKey && ev.shiftKey;
|
||||||
|
|
||||||
|
if (copyModifier && ev.key.toLowerCase() === "c") {
|
||||||
|
const sel = term.getSelection();
|
||||||
|
if (sel) {
|
||||||
|
navigator.clipboard.writeText(sel).catch(() => {});
|
||||||
|
ev.preventDefault();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pasteModifier && ev.key.toLowerCase() === "v") {
|
||||||
|
navigator.clipboard
|
||||||
|
.readText()
|
||||||
|
.then((text) => {
|
||||||
|
if (text) term.paste(text);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
ev.preventDefault();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const fit = new FitAddon();
|
||||||
|
fitRef.current = fit;
|
||||||
|
term.loadAddon(fit);
|
||||||
|
|
||||||
|
const unicode11 = new Unicode11Addon();
|
||||||
|
term.loadAddon(unicode11);
|
||||||
|
term.unicode.activeVersion = "11";
|
||||||
|
|
||||||
|
term.loadAddon(new WebLinksAddon());
|
||||||
|
|
||||||
|
term.open(host);
|
||||||
|
|
||||||
|
// WebGL renderer: rasterizes glyphs to a GPU texture atlas, paints
|
||||||
|
// each cell at an integer-pixel position. Box-drawing glyphs connect
|
||||||
|
// cleanly between rows (no DOM baseline / line-height math). Falls
|
||||||
|
// back to the default DOM renderer if WebGL is unavailable.
|
||||||
|
try {
|
||||||
|
const webgl = new WebglAddon();
|
||||||
|
webgl.onContextLoss(() => webgl.dispose());
|
||||||
|
term.loadAddon(webgl);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(
|
||||||
|
"[hermes-chat] WebGL renderer unavailable; falling back to default",
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial fit + resize observer. fit.fit() reads the container's
|
||||||
|
// current bounding box and resizes the terminal grid to match.
|
||||||
|
//
|
||||||
|
// The subtle bit: the dashboard has CSS transitions on the container
|
||||||
|
// (backdrop fade-in, rounded corners settling as fonts load). If we
|
||||||
|
// call fit() at mount time, the bounding box we measure is often 1-2
|
||||||
|
// cell widths off from the final size. ResizeObserver *does* fire
|
||||||
|
// when the container settles, but if the pixel delta happens to be
|
||||||
|
// smaller than one cell's width, fit() computes the same integer
|
||||||
|
// (cols, rows) as before and doesn't emit onResize — so the PTY
|
||||||
|
// never learns the final size. Users see truncated long lines until
|
||||||
|
// they resize the browser window.
|
||||||
|
//
|
||||||
|
// We force one extra fit + explicit RESIZE send after two animation
|
||||||
|
// frames. rAF→rAF guarantees one layout commit between the two
|
||||||
|
// callbacks, giving CSS transitions and font metrics time to finalize
|
||||||
|
// before we take the authoritative measurement.
|
||||||
|
let rafId = 0;
|
||||||
|
const scheduleFit = () => {
|
||||||
|
if (rafId) return;
|
||||||
|
rafId = requestAnimationFrame(() => {
|
||||||
|
rafId = 0;
|
||||||
|
try {
|
||||||
|
fit.fit();
|
||||||
|
} catch {
|
||||||
|
// Element was removed mid-resize; cleanup will handle it.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
fit.fit();
|
||||||
|
const ro = new ResizeObserver(scheduleFit);
|
||||||
|
ro.observe(host);
|
||||||
|
|
||||||
|
// Double-rAF authoritative fit. On the second frame the layout has
|
||||||
|
// committed at least once since mount; fit.fit() then reads the
|
||||||
|
// stable container size. We always send a RESIZE escape afterwards
|
||||||
|
// (even if fit's cols/rows didn't change, so the PTY has the same
|
||||||
|
// dims registered as our JS state — prevents a drift where Ink
|
||||||
|
// thinks the terminal is one col bigger than what's on screen).
|
||||||
|
let settleRaf1 = 0;
|
||||||
|
let settleRaf2 = 0;
|
||||||
|
settleRaf1 = requestAnimationFrame(() => {
|
||||||
|
settleRaf1 = 0;
|
||||||
|
settleRaf2 = requestAnimationFrame(() => {
|
||||||
|
settleRaf2 = 0;
|
||||||
|
try {
|
||||||
|
fit.fit();
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sock = wsRef.current;
|
||||||
|
if (sock && sock.readyState === WebSocket.OPEN) {
|
||||||
|
sock.send(`\x1b[RESIZE:${term.cols};${term.rows}]`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// WebSocket
|
||||||
|
const url = buildWsUrl(token, resumeRef.current);
|
||||||
|
const ws = new WebSocket(url);
|
||||||
|
ws.binaryType = "arraybuffer";
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
setBanner(null);
|
||||||
|
// Send the initial RESIZE immediately so Ink has *a* size to lay
|
||||||
|
// out against on its first paint. The double-rAF block above will
|
||||||
|
// follow up with the authoritative measurement — at worst Ink
|
||||||
|
// reflows once after the PTY boots, which is imperceptible.
|
||||||
|
ws.send(`\x1b[RESIZE:${term.cols};${term.rows}]`);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
if (typeof ev.data === "string") {
|
||||||
|
term.write(ev.data);
|
||||||
|
} else {
|
||||||
|
term.write(new Uint8Array(ev.data as ArrayBuffer));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = (ev) => {
|
||||||
|
wsRef.current = null;
|
||||||
|
if (ev.code === 4401) {
|
||||||
|
setBanner(
|
||||||
|
"Auth failed. Reload the page to refresh the session token.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ev.code === 4403) {
|
||||||
|
setBanner("Chat is only reachable from localhost.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ev.code === 1011) {
|
||||||
|
// Server already wrote an ANSI error frame.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
term.write("\r\n\x1b[90m[session ended]\x1b[0m\r\n");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keystrokes + mouse events → PTY, with cell-level dedup for motion.
|
||||||
|
//
|
||||||
|
// Ink enables `\x1b[?1003h` (any-motion tracking), which asks the
|
||||||
|
// terminal to report every mouse-move as an SGR mouse event even with
|
||||||
|
// no button held. xterm.js happily emits one report per pixel of
|
||||||
|
// mouse motion; without deduping, a casual mouse-over floods Ink with
|
||||||
|
// hundreds of redraw-triggering reports and the UI goes laggy
|
||||||
|
// (scrolling stutters, clicks land on stale positions by the time
|
||||||
|
// Ink finishes processing the motion backlog).
|
||||||
|
//
|
||||||
|
// We keep track of the last cell we reported a motion for. Press,
|
||||||
|
// release, and wheel events always pass through; motion events only
|
||||||
|
// pass through if the cell changed. Parsing is cheap — SGR reports
|
||||||
|
// are short literal strings.
|
||||||
|
const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/;
|
||||||
|
let lastMotionCell = { col: -1, row: -1 };
|
||||||
|
let lastMotionCb = -1;
|
||||||
|
const onDataDisposable = term.onData((data) => {
|
||||||
|
if (ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
|
||||||
|
const m = SGR_MOUSE_RE.exec(data);
|
||||||
|
if (m) {
|
||||||
|
const cb = parseInt(m[1], 10);
|
||||||
|
const col = parseInt(m[2], 10);
|
||||||
|
const row = parseInt(m[3], 10);
|
||||||
|
const released = m[4] === "m";
|
||||||
|
// Motion events have bit 0x20 (32) set in the button code.
|
||||||
|
// Wheel events have bit 0x40 (64); always forward wheel.
|
||||||
|
const isMotion = (cb & 0x20) !== 0 && (cb & 0x40) === 0;
|
||||||
|
const isWheel = (cb & 0x40) !== 0;
|
||||||
|
if (isMotion && !isWheel && !released) {
|
||||||
|
if (
|
||||||
|
col === lastMotionCell.col &&
|
||||||
|
row === lastMotionCell.row &&
|
||||||
|
cb === lastMotionCb
|
||||||
|
) {
|
||||||
|
return; // same cell + same button state; skip redundant report
|
||||||
|
}
|
||||||
|
lastMotionCell = { col, row };
|
||||||
|
lastMotionCb = cb;
|
||||||
|
} else {
|
||||||
|
// Non-motion event (press, release, wheel) — reset dedup state
|
||||||
|
// so the next motion after this always reports.
|
||||||
|
lastMotionCell = { col: -1, row: -1 };
|
||||||
|
lastMotionCb = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.send(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
const onResizeDisposable = term.onResize(({ cols, rows }) => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(`\x1b[RESIZE:${cols};${rows}]`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
term.focus();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
onDataDisposable.dispose();
|
||||||
|
onResizeDisposable.dispose();
|
||||||
|
ro.disconnect();
|
||||||
|
if (rafId) cancelAnimationFrame(rafId);
|
||||||
|
if (settleRaf1) cancelAnimationFrame(settleRaf1);
|
||||||
|
if (settleRaf2) cancelAnimationFrame(settleRaf2);
|
||||||
|
ws.close();
|
||||||
|
wsRef.current = null;
|
||||||
|
term.dispose();
|
||||||
|
termRef.current = null;
|
||||||
|
fitRef.current = null;
|
||||||
|
if (copyResetRef.current) {
|
||||||
|
clearTimeout(copyResetRef.current);
|
||||||
|
copyResetRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Layout:
|
||||||
|
// outer flex column — sits inside the dashboard's content area
|
||||||
|
// terminal wrapper — rounded, dark, padded — the "terminal window"
|
||||||
|
// floating copy button — bottom-right corner, transparent with a
|
||||||
|
// subtle border; stays out of the way until hovered. Sends
|
||||||
|
// `/copy\n` to Ink, which emits OSC 52 → our clipboard handler.
|
||||||
|
//
|
||||||
|
// `normal-case` opts out of the dashboard's global `uppercase` rule on
|
||||||
|
// the root `<div>` in App.tsx — terminal output must preserve case.
|
||||||
|
return (
|
||||||
|
<div className="flex h-[calc(100vh-10rem)] flex-col gap-2 normal-case">
|
||||||
|
{banner && (
|
||||||
|
<div className="border border-warning/50 bg-warning/10 text-warning px-3 py-2 text-xs tracking-wide">
|
||||||
|
{banner}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="relative flex-1 overflow-hidden rounded-lg"
|
||||||
|
style={{
|
||||||
|
backgroundColor: TERMINAL_THEME.background,
|
||||||
|
padding: "12px",
|
||||||
|
boxShadow: "0 8px 32px rgba(0, 0, 0, 0.4)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div ref={hostRef} className="h-full w-full" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopyLast}
|
||||||
|
title="Copy last assistant response as raw markdown"
|
||||||
|
aria-label="Copy last assistant response"
|
||||||
|
className={[
|
||||||
|
"absolute bottom-4 right-4 z-10",
|
||||||
|
"flex items-center gap-1.5",
|
||||||
|
"rounded border border-current/30",
|
||||||
|
"bg-black/20 backdrop-blur-sm",
|
||||||
|
"px-2.5 py-1.5 text-xs",
|
||||||
|
"opacity-60 hover:opacity-100 hover:border-current/60",
|
||||||
|
"transition-opacity duration-150",
|
||||||
|
"focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-current",
|
||||||
|
"cursor-pointer",
|
||||||
|
].join(" ")}
|
||||||
|
style={{ color: TERMINAL_THEME.foreground }}
|
||||||
|
>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
<span className="tracking-wide">
|
||||||
|
{copyState === "copied" ? "copied" : "copy last response"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__HERMES_SESSION_TOKEN__?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState, useCallback, useRef } from "react";
|
import { useEffect, useState, useCallback, useRef } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
MessageCircle,
|
MessageCircle,
|
||||||
Hash,
|
Hash,
|
||||||
X,
|
X,
|
||||||
|
Play,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { H2 } from "@nous-research/ui";
|
import { H2 } from "@nous-research/ui";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
@@ -250,6 +252,7 @@ function SessionRow({
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isExpanded && messages === null && !loading) {
|
if (isExpanded && messages === null && !loading) {
|
||||||
@@ -329,6 +332,19 @@ function SessionRow({
|
|||||||
<Badge variant="outline" className="text-[10px]">
|
<Badge variant="outline" className="text-[10px]">
|
||||||
{session.source ?? "local"}
|
{session.source ?? "local"}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-muted-foreground hover:text-success"
|
||||||
|
aria-label={t.sessions.resumeInChat}
|
||||||
|
title={t.sessions.resumeInChat}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigate(`/chat?resume=${encodeURIComponent(session.id)}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Play className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|||||||
@@ -64,7 +64,10 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": BACKEND,
|
"/api": {
|
||||||
|
target: BACKEND,
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -60,6 +60,28 @@ The landing page shows a live overview of your installation:
|
|||||||
|
|
||||||
The status page auto-refreshes every 5 seconds.
|
The status page auto-refreshes every 5 seconds.
|
||||||
|
|
||||||
|
### Chat
|
||||||
|
|
||||||
|
The **Chat** tab embeds the full Hermes TUI (the same interface you get from `hermes --tui`) directly in the browser. Everything you can do in the terminal TUI — slash commands, model picker, tool-call cards, markdown streaming, clarify/sudo/approval prompts, skin theming — works identically here, because the dashboard is running the real TUI binary and rendering its ANSI output through [xterm.js](https://xtermjs.org/) with its WebGL renderer for pixel-perfect cell layout.
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
|
||||||
|
- `/api/pty` opens a WebSocket authenticated with the dashboard's session token
|
||||||
|
- The server spawns `hermes --tui` behind a POSIX pseudo-terminal
|
||||||
|
- Keystrokes travel to the PTY; ANSI output streams back to the browser
|
||||||
|
- xterm.js's WebGL renderer paints each cell to an integer-pixel grid; mouse tracking (SGR 1006), wide characters (Unicode 11), and box-drawing glyphs all render natively
|
||||||
|
- Resizing the browser window resizes the TUI via the `@xterm/addon-fit` addon
|
||||||
|
|
||||||
|
**Resume an existing session:** from the **Sessions** tab, click the play icon (▶) next to any session. That jumps to `/chat?resume=<id>` and launches the TUI with `--resume`, loading the full history.
|
||||||
|
|
||||||
|
**Prerequisites:**
|
||||||
|
|
||||||
|
- Node.js (same requirement as `hermes --tui`; the TUI bundle is built on first launch)
|
||||||
|
- `ptyprocess` — included when you `pip install hermes-agent[web]`
|
||||||
|
- POSIX kernel (Linux, macOS, or WSL). Native Windows Python is not supported — use WSL.
|
||||||
|
|
||||||
|
Close the browser tab and the PTY is reaped cleanly on the server. Re-opening spawns a fresh session.
|
||||||
|
|
||||||
### Config
|
### Config
|
||||||
|
|
||||||
A form-based editor for `config.yaml`. All 150+ configuration fields are auto-discovered from `DEFAULT_CONFIG` and organized into tabbed categories:
|
A form-based editor for `config.yaml`. All 150+ configuration fields are auto-discovered from `DEFAULT_CONFIG` and organized into tabbed categories:
|
||||||
|
|||||||
Reference in New Issue
Block a user