feat(web): add /api/pty WebSocket bridge to embed TUI in dashboard

Exposes hermes --tui over a PTY-backed WebSocket so the dashboard can
embed the real TUI rather than reimplement its surface. The browser
attaches xterm.js to the socket; keystrokes flow in, PTY output bytes
flow out.

Architecture:

    browser <Terminal> (xterm.js)
           │  onData ───► ws.send(keystrokes)
           │  onResize ► ws.send('\x1b[RESIZE:cols;rows]')
           │  write   ◄── ws.onmessage (PTY bytes)
           ▼
    FastAPI /api/pty (token-gated, loopback-only)
           ▼
    PtyBridge (ptyprocess) ── spawns node ui-tui/dist/entry.js ──► tui_gateway + AIAgent

Components
----------

hermes_cli/pty_bridge.py
  Thin wrapper around ptyprocess.PtyProcess: byte-safe read/write on the
  master fd via os.read/os.write (not PtyProcessUnicode — ANSI is
  inherently byte-oriented and UTF-8 boundaries may land mid-read),
  non-blocking select-based reads, TIOCSWINSZ resize, idempotent
  SIGHUP→SIGTERM→SIGKILL teardown, platform guard (POSIX-only; Windows
  is WSL-supported only).

hermes_cli/web_server.py
  @app.websocket("/api/pty") endpoint gated by the existing
  _SESSION_TOKEN (via ?token= query param since browsers can't set
  Authorization on WS upgrades). Loopback-only enforcement. Reader task
  uses run_in_executor to pump PTY bytes without blocking the event
  loop. Writer loop intercepts a custom \x1b[RESIZE:cols;rows] escape
  before forwarding to the PTY. The endpoint resolves the TUI argv
  through a _resolve_chat_argv hook so tests can inject fake commands
  without building the real TUI.

Tests
-----

tests/hermes_cli/test_pty_bridge.py — 12 unit tests: spawn, stdout,
stdin round-trip, EOF, resize (via TIOCSWINSZ + tput readback), close
idempotency, cwd, env forwarding, unavailable-platform error.

tests/hermes_cli/test_web_server.py — TestPtyWebSocket adds 7 tests:
missing/bad token rejection (close code 4401), stdout streaming,
stdin round-trip, resize escape forwarding, unavailable-platform ANSI
error frame + 1011 close, resume parameter forwarding to argv.

96 tests pass under scripts/run_tests.sh.

(cherry picked from commit 29b337bca7)

feat(web): add Chat tab with xterm.js terminal + Sessions resume button

(cherry picked from commit 3d21aee8 by emozilla, conflicts resolved
 against current main: BUILTIN_ROUTES table + plugin slot layout)

fix(tui): replace OSC 52 jargon in /copy confirmation

When the user ran /copy successfully, Ink confirmed with:

  sent OSC52 copy sequence (terminal support required)

That reads like a protocol spec to everyone who isn't a terminal
implementer. The caveat was a historical artifact — OSC 52 wasn't
universally supported when this message was written, so the TUI
honestly couldn't guarantee the copy had landed anywhere.

Today every modern terminal (including the dashboard's embedded
xterm.js) handles OSC 52 reliably. Say what the user actually wants
to know — that it copied, and how much — matching the message the
TUI already uses for selection copy:

  copied 1482 chars

(cherry picked from commit a0701b1d5a)

docs: document the dashboard Chat tab

AGENTS.md — new subsection under TUI Architecture explaining that the
dashboard embeds the real hermes --tui rather than rewriting it,
with pointers to the pty_bridge + WebSocket endpoint and the rule
'never add a parallel chat surface in React.'

website/docs/user-guide/features/web-dashboard.md — user-facing Chat
section inside the existing Web Dashboard page, covering how it works
(WebSocket + PTY + xterm.js), the Sessions-page resume flow, and
prerequisites (Node.js, ptyprocess, POSIX kernel / WSL on Windows).

(cherry picked from commit 2c2e32cc45)

feat(tui-gateway): transport-aware dispatch + WebSocket sidecar

Decouples the JSON-RPC dispatcher from its I/O sink so the same handler
surface can drive multiple transports concurrently. The PTY chat tab
already speaks to the TUI binary as bytes — this adds a structured
event channel alongside it for dashboard-side React widgets that need
typed events (tool.start/complete, model picker state, slash catalog)
that PTY can't surface.

- `tui_gateway/transport.py` — `Transport` protocol + `contextvars` binding
  + module-level `StdioTransport` fallback. The stdio stream resolves
  through a lambda so existing tests that monkey-patch `_real_stdout`
  keep passing without modification.
- `tui_gateway/ws.py` — WebSocket transport implementation; FastAPI
  endpoint mounting lives in hermes_cli/web_server.py.
- `tui_gateway/server.py`:
  - `write_json` routes via session transport (for async events) →
    contextvar transport (for in-request writes) → stdio fallback.
  - `dispatch(req, transport=None)` binds the transport for the request
    lifetime and propagates it to pool workers via `contextvars.copy_context`
    so async handlers don't lose their sink.
  - `_init_session` and the manual-session create path stash the
    request's transport so out-of-band events (subagent.complete, etc.)
    fan out to the right peer.

`tui_gateway.entry` (Ink's stdio handshake) is unchanged externally —
it falls through every precedence step into the stdio fallback, byte-
identical to the previous behaviour.

feat(web): ChatSidebar — JSON-RPC sidecar next to xterm.js terminal

Composes the two transports into a single Chat tab:

  ┌─────────────────────────────────────────┬──────────────┐
  │  xterm.js / PTY  (emozilla #13379)      │ ChatSidebar  │
  │  the literal hermes --tui process       │  /api/ws     │
  └─────────────────────────────────────────┴──────────────┘
        terminal bytes                          structured events

The terminal pane stays the canonical chat surface — full TUI fidelity,
slash commands, model picker, mouse, skin engine, wide chars all paint
inside the terminal. The sidebar opens a parallel JSON-RPC WebSocket
to the same gateway and renders metadata that PTY can't surface to
React chrome:

  • model + provider badge with connection state (click → switch)
  • running tool-call list (driven by tool.start / tool.progress /
    tool.complete events)
  • model picker dialog (gateway-driven, reuses ModelPickerDialog)

The sidecar is best-effort. If the WS can't connect (older gateway,
network hiccup, missing token) the terminal pane keeps working
unimpaired — sidebar just shows the connection-state badge in the
appropriate tone.

- `web/src/components/ChatSidebar.tsx` — new component (~270 lines).
  Owns its GatewayClient, drives the model picker through
  `slash.exec`, fans tool events into a capped tool list.
- `web/src/pages/ChatPage.tsx` — split layout: terminal pane
  (`flex-1`) + sidebar (`w-80`, `lg+` only).
- `hermes_cli/web_server.py` — mount `/api/ws` (token + loopback
  guards mirror /api/pty), delegate to `tui_gateway.ws.handle_ws`.

Co-authored-by: emozilla <emozilla@nousresearch.com>

refactor(web): /clean pass on ChatSidebar + ChatPage lint debt

- ChatSidebar: lift gw out of useRef into a useMemo derived from a
  reconnect counter. React 19's react-hooks/refs and react-hooks/
  set-state-in-effect rules both fire when you touch a ref during
  render or call setState from inside a useEffect body. The
  counter-derived gw is the canonical pattern for "external resource
  that needs to be replaceable on user action" — re-creating the
  client comes from bumping `version`, the effect just wires + tears
  down. Drops the imperative `gwRef.current = …` reassign in
  reconnect, drops the truthy ref guard in JSX. modelLabel +
  banner inlined as derived locals (one-off useMemo was overkill).
- ChatPage: lazy-init the banner state from the missing-token check
  so the effect body doesn't have to setState on first run. Drops
  the unused react-hooks/exhaustive-deps eslint-disable. Adds a
  scoped no-control-regex disable on the SGR mouse parser regex
  (the \\x1b is intentional for xterm escape sequences).

All my-touched files now lint clean. Remaining warnings on web/
belong to pre-existing files this PR doesn't touch.

Verified: vitest 249/249, ui-tui eslint clean, web tsc clean,
python imports clean.

chore: uptick

fix(web): drop ChatSidebar tool list — events can't cross PTY/WS boundary

The /api/pty endpoint spawns `hermes --tui` as a child process with its
own tui_gateway and _sessions dict; /api/ws runs handle_ws in-process in
the dashboard server with a separate _sessions dict. Tool events fire on
the child's gateway and never reach the WS sidecar, so the sidebar's
tool.start/progress/complete listeners always observed an empty list.

Drop the misleading list (and the now-orphaned ToolCall primitive),
keep model badge + connection state + model picker + error banner —
those work because they're sidecar-local concerns. Surfacing tool calls
in the sidebar requires cross-process forwarding (PTY child opens a
back-WS to the dashboard, gateway tees emits onto stdio + sidecar
transport) — proper feature for a follow-up.

feat(web): wire ChatSidebar tool list to PTY child via /api/pub broadcast

The dashboard's /api/pty spawns hermes --tui as a child process; tool
events fire in the python tui_gateway grandchild and never crossed the
process boundary into the in-process WS sidecar — so the sidebar tool
list was always empty.

Cross-process forwarding:

- tui_gateway: TeeTransport (transport.py) + WsPublisherTransport
  (event_publisher.py, sync websockets client). entry.py installs the
  tee on _stdio_transport when HERMES_TUI_SIDECAR_URL is set, mirroring
  every dispatcher emit to a back-WS without disturbing Ink's stdio
  handshake.

- hermes_cli/web_server.py: new /api/pub (publisher) + /api/events
  (subscriber) endpoints with a per-channel registry. /api/pty now
  accepts ?channel= and propagates the sidecar URL via env. start_server
  also stashes app.state.bound_port so the URL is constructable.

- web/src/pages/ChatPage.tsx: generates a channel UUID per mount,
  passes it to /api/pty and as a prop to ChatSidebar.

- web/src/components/ChatSidebar.tsx: opens /api/events?channel=, fans
  tool.start/progress/complete back into the ToolCall list. Restores
  the ToolCall primitive.

Tests: 4 new TestPtyWebSocket cases cover channel propagation,
broadcast fan-out, and missing-channel rejection (10 PTY tests pass,
120 web_server tests overall).

fix(web): address Copilot review on #14890

Five threads, all real:

- gatewayClient.ts: register `message`/`close` listeners BEFORE awaiting
  the open handshake.  Server emits `gateway.ready` immediately after
  accept, so a listener attached after the open promise could race past
  the initial skin payload and lose it.

- ChatSidebar.tsx: wire `error`/`close` on the /api/events subscriber
  WS into the existing error banner.  4401/4403 (auth/loopback reject)
  surface as a "reload the page" message; mid-stream drops surface as
  "events feed disconnected" with the existing reconnect button.  Clean
  unmount closes (1000/1001) stay silent.

- web-dashboard.md: install hint was `pip install hermes-agent[web]` but
  ptyprocess lives in the `pty` extra, not `web`.  Switch to
  `hermes-agent[web,pty]` in both prerequisite blocks.

- AGENTS.md: previous "never add a parallel React chat surface" guidance
  was overbroad and contradicted this PR's sidebar.  Tightened to forbid
  re-implementing the transcript/composer/PTY terminal while explicitly
  allowing structured supporting widgets (sidebar / model picker /
  inspectors), matching the actual architecture.

- web/package-lock.json: regenerated cleanly so the wterm sibling
  workspace paths (extraneous machine-local entries) stop polluting CI.

Tests: 249/249 vitest, 10/10 PTY/events, web tsc clean.

refactor(web): /clean pass on ChatSidebar events handler

Spotted in the round-2 review:

- Banner flashed on clean unmount: `ws.close()` from the effect cleanup
  fires `close` with code 1005, opened=true, neither 1000 nor 1001 —
  hit the "unexpected drop" branch.  Track `unmounting` in the effect
  scope and gate the banner through a `surface()` helper so cleanup
  closes stay silent.

- DRY the duplicated "events feed disconnected" string into a local
  const used by both the error and close handlers.

- Drop the `opened` flag (no longer needed once the unmount guard is
  the source of truth for "is this an expected close?").
This commit is contained in:
emozilla 2026-04-21 02:09:12 -04:00 committed by Austin Pickett
parent 1143f234e3
commit f49afd3122
33 changed files with 4259 additions and 612 deletions

View file

@ -240,6 +240,19 @@ 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`.
**Do not re-implement the primary chat experience in React.** The main transcript, composer/input flow (including slash-command behavior), and PTY-backed terminal belong to the embedded `hermes --tui` — anything new you add to Ink shows up in the dashboard automatically. If you find yourself rebuilding the transcript or composer for the dashboard, stop and extend Ink instead.
**Structured React UI around the TUI is allowed when it is not a second chat surface.** Sidebar widgets, inspectors, summaries, status panels, and similar supporting views (e.g. `ChatSidebar`, `ModelPickerDialog`, `ToolCall`) are fine when they complement the embedded TUI rather than replacing the transcript / composer / terminal. Keep their state independent of the PTY child's session and surface their failures non-destructively so the terminal pane keeps working unimpaired.
--- ---
## Adding New Tools ## Adding New Tools

221
hermes_cli/pty_bridge.py Normal file
View 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()

View file

@ -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
@ -2263,6 +2263,313 @@ 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
_VALID_CHANNEL_RE = re.compile(r"^[A-Za-z0-9._-]{1,128}$")
# 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"})
# Per-channel subscriber registry used by /api/pub (PTY-side gateway → dashboard)
# and /api/events (dashboard → browser sidebar). Keyed by an opaque channel id
# the chat tab generates on mount; entries auto-evict when the last subscriber
# drops AND the publisher has disconnected.
_event_channels: dict[str, set] = {}
_event_lock = asyncio.Lock()
def _resolve_chat_argv(
resume: Optional[str] = None,
sidecar_url: 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.
`sidecar_url` (when set) is forwarded as ``HERMES_TUI_SIDECAR_URL`` so
the spawned ``tui_gateway.entry`` can mirror dispatcher emits to the
dashboard's ``/api/pub`` endpoint (see :func:`pub_ws`).
"""
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 or sidecar_url:
env = os.environ.copy()
if resume:
env["HERMES_TUI_RESUME"] = resume
if sidecar_url:
env["HERMES_TUI_SIDECAR_URL"] = sidecar_url
return list(argv), str(cwd) if cwd else None, env
def _build_sidecar_url(channel: str) -> Optional[str]:
"""ws:// URL the PTY child should publish events to, or None when unbound."""
host = getattr(app.state, "bound_host", None)
port = getattr(app.state, "bound_port", None)
if not host or not port:
return None
netloc = f"[{host}]:{port}" if ":" in host and not host.startswith("[") else f"{host}:{port}"
qs = urllib.parse.urlencode({"token": _SESSION_TOKEN, "channel": channel})
return f"ws://{netloc}/api/pub?{qs}"
async def _broadcast_event(channel: str, payload: str) -> None:
"""Fan out one publisher frame to every subscriber on `channel`."""
async with _event_lock:
subs = list(_event_channels.get(channel, ()))
for sub in subs:
try:
await sub.send_text(payload)
except Exception:
# Subscriber went away mid-send; the /api/events finally clause
# will remove it from the registry on its next iteration.
pass
def _channel_or_close_code(ws: WebSocket) -> Optional[str]:
"""Return the channel id from the query string or None if invalid."""
channel = ws.query_params.get("channel", "")
return channel if _VALID_CHANNEL_RE.match(channel) else None
@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
channel = _channel_or_close_code(ws)
sidecar_url = _build_sidecar_url(channel) if channel else None
try:
argv, cwd, env = _resolve_chat_argv(resume=resume, sidecar_url=sidecar_url)
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()
# ---------------------------------------------------------------------------
# /api/ws — JSON-RPC WebSocket sidecar for the dashboard "Chat" tab.
#
# Drives the same `tui_gateway.dispatch` surface Ink uses over stdio, so the
# dashboard can render structured metadata (model badge, tool-call sidebar,
# slash launcher, session info) alongside the xterm.js terminal that PTY
# already paints. Both transports bind to the same session id when one is
# active, so a tool.start emitted by the agent fans out to both sinks.
# ---------------------------------------------------------------------------
@app.websocket("/api/ws")
async def gateway_ws(ws: WebSocket) -> None:
token = ws.query_params.get("token", "")
if not hmac.compare_digest(token.encode(), _SESSION_TOKEN.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
from tui_gateway.ws import handle_ws
await handle_ws(ws)
# ---------------------------------------------------------------------------
# /api/pub + /api/events — chat-tab event broadcast.
#
# The PTY-side ``tui_gateway.entry`` opens /api/pub at startup (driven by
# HERMES_TUI_SIDECAR_URL set in /api/pty's PTY env) and writes every
# dispatcher emit through it. The dashboard fans those frames out to any
# subscriber that opened /api/events on the same channel id. This is what
# gives the React sidebar its tool-call feed without breaking the PTY
# child's stdio handshake with Ink.
# ---------------------------------------------------------------------------
@app.websocket("/api/pub")
async def pub_ws(ws: WebSocket) -> None:
token = ws.query_params.get("token", "")
if not hmac.compare_digest(token.encode(), _SESSION_TOKEN.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
channel = _channel_or_close_code(ws)
if not channel:
await ws.close(code=4400)
return
await ws.accept()
try:
while True:
await _broadcast_event(channel, await ws.receive_text())
except WebSocketDisconnect:
pass
@app.websocket("/api/events")
async def events_ws(ws: WebSocket) -> None:
token = ws.query_params.get("token", "")
if not hmac.compare_digest(token.encode(), _SESSION_TOKEN.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
channel = _channel_or_close_code(ws)
if not channel:
await ws.close(code=4400)
return
await ws.accept()
async with _event_lock:
_event_channels.setdefault(channel, set()).add(ws)
try:
while True:
# Subscribers don't speak — the receive() just blocks until
# disconnect so the connection stays open as long as the
# browser holds it.
await ws.receive_text()
except WebSocketDisconnect:
pass
finally:
async with _event_lock:
subs = _event_channels.get(channel)
if subs is not None:
subs.discard(ws)
if not subs:
_event_channels.pop(channel, None)
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.
@ -2817,7 +3124,10 @@ def start_server(
# Record the bound host so host_header_middleware can validate incoming # Record the bound host so host_header_middleware can validate incoming
# Host headers against it. Defends against DNS rebinding (GHSA-ppp5-vxwm-4cf7). # Host headers against it. Defends against DNS rebinding (GHSA-ppp5-vxwm-4cf7).
# bound_port is also stashed so /api/pty can build the back-WS URL the
# PTY child uses to publish events to the dashboard sidebar.
app.state.bound_host = host app.state.bound_host = host
app.state.bound_port = port
if open_browser: if open_browser:
import webbrowser import webbrowser

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

View file

@ -1677,3 +1677,241 @@ class TestDashboardPluginManifestExtensions:
plugins = web_server._get_dashboard_plugins(force_rescan=True) plugins = web_server._get_dashboard_plugins(force_rescan=True)
entry = next(p for p in plugins if p["name"] == "mixed-slots") entry = next(p for p in plugins if p["name"] == "mixed-slots")
assert entry["slots"] == ["sidebar", "header-right"] assert entry["slots"] == ["sidebar", "header-right"]
# ---------------------------------------------------------------------------
# /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, sidecar_url=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, sidecar_url=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, sidecar_url=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, sidecar_url=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, sidecar_url=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, sidecar_url=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, sidecar_url=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"
def test_channel_param_propagates_sidecar_url(self, monkeypatch):
"""When /api/pty is opened with ?channel=, the PTY child gets a
HERMES_TUI_SIDECAR_URL env var pointing back at /api/pub on the
same channel which is how tool events reach the dashboard sidebar."""
captured: dict = {}
def fake_resolve(resume=None, sidecar_url=None):
captured["sidecar_url"] = sidecar_url
return (["/bin/sh", "-c", "printf sidecar-ok"], None, None)
monkeypatch.setattr(self.ws_module, "_resolve_chat_argv", fake_resolve)
monkeypatch.setattr(
self.ws_module.app.state, "bound_host", "127.0.0.1", raising=False
)
monkeypatch.setattr(
self.ws_module.app.state, "bound_port", 9119, raising=False
)
with self.client.websocket_connect(self._url(channel="abc-123")) as conn:
try:
conn.receive_bytes()
except Exception:
pass
url = captured.get("sidecar_url") or ""
assert url.startswith("ws://127.0.0.1:9119/api/pub?")
assert "channel=abc-123" in url
assert "token=" in url
def test_pub_broadcasts_to_events_subscribers(self, monkeypatch):
"""Frame written to /api/pub is rebroadcast verbatim to every
/api/events subscriber on the same channel."""
from urllib.parse import urlencode
qs = urlencode({"token": self.token, "channel": "broadcast-test"})
pub_path = f"/api/pub?{qs}"
sub_path = f"/api/events?{qs}"
with self.client.websocket_connect(sub_path) as sub:
with self.client.websocket_connect(pub_path) as pub:
pub.send_text('{"type":"tool.start","payload":{"tool_id":"t1"}}')
received = sub.receive_text()
assert "tool.start" in received
assert '"tool_id":"t1"' in received
def test_events_rejects_missing_channel(self):
from starlette.websockets import WebSocketDisconnect
with pytest.raises(WebSocketDisconnect) as exc:
with self.client.websocket_connect(
f"/api/events?token={self.token}"
):
pass
assert exc.value.code == 4400

View file

@ -5,7 +5,28 @@ import sys
import time import time
import traceback import traceback
from tui_gateway import server
from tui_gateway.server import _CRASH_LOG, dispatch, resolve_skin, write_json from tui_gateway.server import _CRASH_LOG, dispatch, resolve_skin, write_json
from tui_gateway.transport import TeeTransport
def _install_sidecar_publisher() -> None:
"""Mirror every dispatcher emit to the dashboard sidebar via WS.
Activated by `HERMES_TUI_SIDECAR_URL`, set by the dashboard's
``/api/pty`` endpoint when a chat tab passes a ``channel`` query param.
Best-effort: connect failure or runtime drop falls back to stdio-only.
"""
url = os.environ.get("HERMES_TUI_SIDECAR_URL")
if not url:
return
from tui_gateway.event_publisher import WsPublisherTransport
server._stdio_transport = TeeTransport(
server._stdio_transport, WsPublisherTransport(url)
)
def _log_signal(signum: int, frame) -> None: def _log_signal(signum: int, frame) -> None:
@ -82,6 +103,8 @@ def _log_exit(reason: str) -> None:
def main(): def main():
_install_sidecar_publisher()
if not write_json({ if not write_json({
"jsonrpc": "2.0", "jsonrpc": "2.0",
"method": "event", "method": "event",

View file

@ -0,0 +1,81 @@
"""Best-effort WebSocket publisher transport for the PTY-side gateway.
The dashboard's `/api/pty` spawns `hermes --tui` as a child process, which
spawns its own ``tui_gateway.entry``. Tool/reasoning/status events fire on
*that* gateway's transport — three processes removed from the dashboard
server itself. To surface them in the dashboard sidebar (`/api/events`),
the PTY-side gateway opens a back-WS to the dashboard at startup and
mirrors every emit through this transport.
Wire protocol: newline-framed JSON dicts (the same shape the dispatcher
already passes to ``write``). No JSON-RPC envelope here the dashboard's
``/api/pub`` endpoint just rebroadcasts the bytes verbatim to subscribers.
Failure mode: silent. The agent loop must never block waiting for the
sidecar to drain. A dead WS short-circuits all subsequent writes.
"""
from __future__ import annotations
import json
import logging
import threading
from typing import Optional
try:
from websockets.sync.client import connect as ws_connect
except ImportError: # pragma: no cover - websockets is a required install path
ws_connect = None # type: ignore[assignment]
_log = logging.getLogger(__name__)
class WsPublisherTransport:
__slots__ = ("_url", "_lock", "_ws", "_dead")
def __init__(self, url: str, *, connect_timeout: float = 2.0) -> None:
self._url = url
self._lock = threading.Lock()
self._ws: Optional[object] = None
self._dead = False
if ws_connect is None:
self._dead = True
return
try:
self._ws = ws_connect(url, open_timeout=connect_timeout, max_size=None)
except Exception as exc:
_log.debug("event publisher connect failed: %s", exc)
self._dead = True
self._ws = None
def write(self, obj: dict) -> bool:
if self._dead or self._ws is None:
return False
try:
with self._lock:
self._ws.send(json.dumps(obj, ensure_ascii=False)) # type: ignore[union-attr]
return True
except Exception as exc:
_log.debug("event publisher write failed: %s", exc)
self._dead = True
self._ws = None
return False
def close(self) -> None:
self._dead = True
if self._ws is None:
return
try:
self._ws.close() # type: ignore[union-attr]
except Exception:
pass
self._ws = None

View file

@ -1,5 +1,6 @@
import atexit import atexit
import concurrent.futures import concurrent.futures
import contextvars
import copy import copy
import json import json
import logging import logging
@ -12,9 +13,17 @@ import time
import uuid import uuid
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Optional
from hermes_constants import get_hermes_home from hermes_constants import get_hermes_home
from hermes_cli.env_loader import load_hermes_dotenv from hermes_cli.env_loader import load_hermes_dotenv
from tui_gateway.transport import (
StdioTransport,
Transport,
bind_transport,
current_transport,
reset_transport,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -147,6 +156,11 @@ atexit.register(lambda: _pool.shutdown(wait=False, cancel_futures=True))
_real_stdout = sys.stdout _real_stdout = sys.stdout
sys.stdout = sys.stderr sys.stdout = sys.stderr
# Module-level stdio transport — fallback sink when no transport is bound via
# contextvar or session. Stream resolved through a lambda so runtime monkey-
# patches of `_real_stdout` (used extensively in tests) still land correctly.
_stdio_transport = StdioTransport(lambda: _real_stdout, _stdout_lock)
class _SlashWorker: class _SlashWorker:
"""Persistent HermesCLI subprocess for slash commands.""" """Persistent HermesCLI subprocess for slash commands."""
@ -266,14 +280,24 @@ def _db_unavailable_error(rid, *, code: int):
def write_json(obj: dict) -> bool: def write_json(obj: dict) -> bool:
line = json.dumps(obj, ensure_ascii=False) + "\n" """Emit one JSON frame. Routes via the most-specific transport available.
try:
with _stdout_lock: Precedence:
_real_stdout.write(line)
_real_stdout.flush() 1. Event frames with a session id the transport stored on that session,
return True so async events land with the client that owns the session even if
except BrokenPipeError: the emitting thread has no contextvar binding.
return False 2. Otherwise the transport bound on the current context (set by
:func:`dispatch` for the lifetime of a request).
3. Otherwise the module-level stdio transport, matching the historical
behaviour and keeping tests that monkey-patch ``_real_stdout`` green.
"""
if obj.get("method") == "event":
sid = ((obj.get("params") or {}).get("session_id")) or ""
if sid and (t := (_sessions.get(sid) or {}).get("transport")) is not None:
return t.write(obj)
return (current_transport() or _stdio_transport).write(obj)
def _emit(event: str, sid: str, payload: dict | None = None): def _emit(event: str, sid: str, payload: dict | None = None):
@ -343,27 +367,40 @@ def handle_request(req: dict) -> dict | None:
return fn(req.get("id"), req.get("params", {})) return fn(req.get("id"), req.get("params", {}))
def dispatch(req: dict) -> dict | None: def dispatch(req: dict, transport: Optional[Transport] = None) -> dict | None:
"""Route inbound RPCs — long handlers to the pool, everything else inline. """Route inbound RPCs — long handlers to the pool, everything else inline.
Returns a response dict when handled inline. Returns None when the Returns a response dict when handled inline. Returns None when the
handler was scheduled on the pool; the worker writes its own handler was scheduled on the pool; the worker writes its own response
response via write_json when done. via the bound transport when done.
*transport* (optional): pins every write produced by this request
including any events emitted by the handler to the given transport.
Omitting it falls back to the module-level stdio transport, preserving
the original behaviour for ``tui_gateway.entry``.
""" """
t = transport or _stdio_transport
token = bind_transport(t)
try:
if req.get("method") not in _LONG_HANDLERS: if req.get("method") not in _LONG_HANDLERS:
return handle_request(req) return handle_request(req)
# Snapshot the context so the pool worker sees the bound transport.
ctx = contextvars.copy_context()
def run(): def run():
try: try:
resp = handle_request(req) resp = handle_request(req)
except Exception as exc: except Exception as exc:
resp = _err(req.get("id"), -32000, f"handler error: {exc}") resp = _err(req.get("id"), -32000, f"handler error: {exc}")
if resp is not None: if resp is not None:
write_json(resp) t.write(resp)
_pool.submit(run) _pool.submit(lambda: ctx.run(run))
return None return None
finally:
reset_transport(token)
def _wait_agent(session: dict, rid: str, timeout: float = 30.0) -> dict | None: def _wait_agent(session: dict, rid: str, timeout: float = 30.0) -> dict | None:
@ -1262,6 +1299,9 @@ def _init_session(sid: str, key: str, agent, history: list, cols: int = 80):
"tool_progress_mode": _load_tool_progress_mode(), "tool_progress_mode": _load_tool_progress_mode(),
"edit_snapshots": {}, "edit_snapshots": {},
"tool_started_at": {}, "tool_started_at": {},
# Pin async event emissions to whichever transport created the
# session (stdio for Ink, JSON-RPC WS for the dashboard sidebar).
"transport": current_transport() or _stdio_transport,
} }
try: try:
_sessions[sid]["slash_worker"] = _SlashWorker( _sessions[sid]["slash_worker"] = _SlashWorker(
@ -1404,6 +1444,7 @@ def _(rid, params: dict) -> dict:
"slash_worker": None, "slash_worker": None,
"tool_progress_mode": _load_tool_progress_mode(), "tool_progress_mode": _load_tool_progress_mode(),
"tool_started_at": {}, "tool_started_at": {},
"transport": current_transport() or _stdio_transport,
} }
def _build() -> None: def _build() -> None:

125
tui_gateway/transport.py Normal file
View file

@ -0,0 +1,125 @@
"""Transport abstraction for the tui_gateway JSON-RPC server.
Historically the gateway wrote every JSON frame directly to real stdout. This
module decouples the I/O sink from the handler logic so the same dispatcher
can be driven over stdio (``tui_gateway.entry``) or WebSocket
(``tui_gateway.ws``) without duplicating code.
A :class:`Transport` is anything that can accept a JSON-serialisable dict and
forward it to its peer. The active transport for the current request is
tracked in a :class:`contextvars.ContextVar` so handlers including those
dispatched onto the worker pool route their writes to the right peer.
Backward compatibility
----------------------
``tui_gateway.server.write_json`` still works without any transport bound.
When nothing is on the contextvar and no session-level transport is found,
it falls back to the module-level :class:`StdioTransport`, which wraps the
original ``_real_stdout`` + ``_stdout_lock`` pair. Tests that monkey-patch
``server._real_stdout`` continue to work because the stdio transport resolves
the stream lazily through a callback.
"""
from __future__ import annotations
import contextvars
import json
import threading
from typing import Any, Callable, Optional, Protocol, runtime_checkable
@runtime_checkable
class Transport(Protocol):
"""Minimal interface every transport implements."""
def write(self, obj: dict) -> bool:
"""Emit one JSON frame. Return ``False`` when the peer is gone."""
def close(self) -> None:
"""Release any resources owned by this transport."""
_current_transport: contextvars.ContextVar[Optional[Transport]] = (
contextvars.ContextVar(
"hermes_gateway_transport",
default=None,
)
)
def current_transport() -> Optional[Transport]:
"""Return the transport bound for the current request, if any."""
return _current_transport.get()
def bind_transport(transport: Optional[Transport]):
"""Bind *transport* for the current context. Returns a token for :func:`reset_transport`."""
return _current_transport.set(transport)
def reset_transport(token) -> None:
"""Restore the transport binding captured by :func:`bind_transport`."""
_current_transport.reset(token)
class StdioTransport:
"""Writes JSON frames to a stream (usually ``sys.stdout``).
The stream is resolved via a callable so runtime monkey-patches of the
underlying stream continue to work this preserves the behaviour the
existing test suite relies on (``monkeypatch.setattr(server, "_real_stdout", ...)``).
"""
__slots__ = ("_stream_getter", "_lock")
def __init__(self, stream_getter: Callable[[], Any], lock: threading.Lock) -> None:
self._stream_getter = stream_getter
self._lock = lock
def write(self, obj: dict) -> bool:
line = json.dumps(obj, ensure_ascii=False) + "\n"
try:
with self._lock:
stream = self._stream_getter()
stream.write(line)
stream.flush()
return True
except BrokenPipeError:
return False
def close(self) -> None:
return None
class TeeTransport:
"""Mirrors writes to one primary plus N best-effort secondaries.
The primary's return value (and exceptions) determine the result —
secondaries swallow failures so a wedged sidecar never stalls the
main IO path. Used by the PTY child so every dispatcher emit lands
on stdio (Ink) AND on a back-WS feeding the dashboard sidebar.
"""
__slots__ = ("_primary", "_secondaries")
def __init__(self, primary: "Transport", *secondaries: "Transport") -> None:
self._primary = primary
self._secondaries = secondaries
def write(self, obj: dict) -> bool:
for sec in self._secondaries:
try:
sec.write(obj)
except Exception:
pass
return self._primary.write(obj)
def close(self) -> None:
try:
self._primary.close()
finally:
for sec in self._secondaries:
try:
sec.close()
except Exception:
pass

174
tui_gateway/ws.py Normal file
View file

@ -0,0 +1,174 @@
"""WebSocket transport for the tui_gateway JSON-RPC server.
Reuses :func:`tui_gateway.server.dispatch` verbatim so every RPC method, every
slash command, every approval/clarify/sudo flow, and every agent event flows
through the same handlers whether the client is Ink over stdio or an iOS /
web client over WebSocket.
Wire protocol
-------------
Identical to stdio: newline-delimited JSON-RPC in both directions. The server
emits a ``gateway.ready`` event immediately after connection accept, then
echoes responses/events for inbound requests. No framing differences.
Mounting
--------
from fastapi import WebSocket
from tui_gateway.ws import handle_ws
@app.websocket("/api/ws")
async def ws(ws: WebSocket):
await handle_ws(ws)
"""
from __future__ import annotations
import asyncio
import json
import logging
from typing import Any
from tui_gateway import server
_log = logging.getLogger(__name__)
# Max seconds a pool-dispatched handler will block waiting for the event loop
# to flush a WS frame before we mark the transport dead. Protects handler
# threads from a wedged socket.
_WS_WRITE_TIMEOUT_S = 10.0
# Keep starlette optional at import time; handle_ws uses the real class when
# it's available and falls back to a generic Exception sentinel otherwise.
try:
from starlette.websockets import WebSocketDisconnect as _WebSocketDisconnect
except ImportError: # pragma: no cover - starlette is a required install path
_WebSocketDisconnect = Exception # type: ignore[assignment]
class WSTransport:
"""Per-connection WS transport.
``write`` is safe to call from any thread *other than* the event loop
thread that owns the socket. Pool workers (the only real caller) run in
their own threads, so marshalling onto the loop via
:func:`asyncio.run_coroutine_threadsafe` + ``future.result()`` is correct
and deadlock-free there.
When called from the loop thread itself (e.g. by ``handle_ws`` for an
inline response) the same call would deadlock: we'd schedule work onto
the loop we're currently blocking. We detect that case and fire-and-
forget instead. Callers that need to know when the bytes are on the wire
should use :meth:`write_async` from the loop thread.
"""
def __init__(self, ws: Any, loop: asyncio.AbstractEventLoop) -> None:
self._ws = ws
self._loop = loop
self._closed = False
def write(self, obj: dict) -> bool:
if self._closed:
return False
line = json.dumps(obj, ensure_ascii=False)
try:
on_loop = asyncio.get_running_loop() is self._loop
except RuntimeError:
on_loop = False
if on_loop:
# Fire-and-forget — don't block the loop waiting on itself.
self._loop.create_task(self._safe_send(line))
return True
try:
fut = asyncio.run_coroutine_threadsafe(self._safe_send(line), self._loop)
fut.result(timeout=_WS_WRITE_TIMEOUT_S)
return not self._closed
except Exception as exc:
self._closed = True
_log.debug("ws write failed: %s", exc)
return False
async def write_async(self, obj: dict) -> bool:
"""Send from the owning event loop. Awaits until the frame is on the wire."""
if self._closed:
return False
await self._safe_send(json.dumps(obj, ensure_ascii=False))
return not self._closed
async def _safe_send(self, line: str) -> None:
try:
await self._ws.send_text(line)
except Exception as exc:
self._closed = True
_log.debug("ws send failed: %s", exc)
def close(self) -> None:
self._closed = True
async def handle_ws(ws: Any) -> None:
"""Run one WebSocket session. Wire-compatible with ``tui_gateway.entry``."""
await ws.accept()
transport = WSTransport(ws, asyncio.get_running_loop())
await transport.write_async(
{
"jsonrpc": "2.0",
"method": "event",
"params": {
"type": "gateway.ready",
"payload": {"skin": server.resolve_skin()},
},
}
)
try:
while True:
try:
raw = await ws.receive_text()
except _WebSocketDisconnect:
break
line = raw.strip()
if not line:
continue
try:
req = json.loads(line)
except json.JSONDecodeError:
ok = await transport.write_async(
{
"jsonrpc": "2.0",
"error": {"code": -32700, "message": "parse error"},
"id": None,
}
)
if not ok:
break
continue
# dispatch() may schedule long handlers on the pool; it returns
# None in that case and the worker writes the response itself via
# the transport we pass in (a separate thread, so transport.write
# is the safe path there). For inline handlers it returns the
# response dict, which we write here from the loop.
resp = await asyncio.to_thread(server.dispatch, req, transport)
if resp is not None and not await transport.write_async(resp):
break
finally:
transport.close()
# Detach the transport from any sessions it owned so later emits
# fall back to stdio instead of crashing into a closed socket.
for _, sess in list(server._sessions.items()):
if sess.get("transport") is transport:
sess["transport"] = server._stdio_transport
try:
await ws.close()
except Exception:
pass

View file

@ -246,7 +246,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`)
} }
}, },

1168
web/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -58,6 +58,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";
@ -72,6 +73,7 @@ function RootRedirect() {
/** Built-in route → page component. Used for routing and for plugin `tab.path` / `tab.override` resolution. */ /** Built-in route → page component. Used for routing and for plugin `tab.path` / `tab.override` resolution. */
const BUILTIN_ROUTES: Record<string, ComponentType> = { const BUILTIN_ROUTES: Record<string, ComponentType> = {
"/": RootRedirect, "/": RootRedirect,
"/chat": ChatPage,
"/sessions": SessionsPage, "/sessions": SessionsPage,
"/analytics": AnalyticsPage, "/analytics": AnalyticsPage,
"/logs": LogsPage, "/logs": LogsPage,
@ -83,6 +85,7 @@ const BUILTIN_ROUTES: Record<string, ComponentType> = {
}; };
const BUILTIN_NAV: NavItem[] = [ const BUILTIN_NAV: NavItem[] = [
{ path: "/chat", labelKey: "chat", label: "Chat", icon: Terminal },
{ {
path: "/sessions", path: "/sessions",
labelKey: "sessions", labelKey: "sessions",

View file

@ -0,0 +1,360 @@
/**
* ChatSidebar structured-events panel that sits next to the xterm.js
* terminal in the dashboard Chat tab.
*
* Two WebSockets, one per concern:
*
* 1. **JSON-RPC sidecar** (`GatewayClient` /api/ws) drives the
* sidebar's own slot of the dashboard's in-process gateway. Owns
* the model badge / picker / connection state / error banner.
* Independent of the PTY pane's session by design those are the
* pieces the sidebar needs to be able to drive directly (model
* switch via slash.exec, etc.).
*
* 2. **Event subscriber** (/api/events?channel=) passive, receives
* every dispatcher emit from the PTY-side `tui_gateway.entry` that
* the dashboard fanned out. This is how `tool.start/progress/
* complete` from the agent loop reach the sidebar even though the
* PTY child runs three processes deep from us. The `channel` id
* ties this listener to the same chat tab's PTY child see
* `ChatPage.tsx` for where the id is generated.
*
* Best-effort throughout: WS failures show in the badge / banner, the
* terminal pane keeps working unimpaired.
*/
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { ModelPickerDialog } from "@/components/ModelPickerDialog";
import { ToolCall, type ToolEntry } from "@/components/ToolCall";
import { GatewayClient, type ConnectionState } from "@/lib/gatewayClient";
import { AlertCircle, ChevronDown, RefreshCw } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
interface SessionInfo {
cwd?: string;
model?: string;
provider?: string;
credential_warning?: string;
}
interface RpcEnvelope {
method?: string;
params?: { type?: string; payload?: unknown };
}
const TOOL_LIMIT = 20;
const STATE_LABEL: Record<ConnectionState, string> = {
idle: "idle",
connecting: "connecting",
open: "live",
closed: "closed",
error: "error",
};
const STATE_TONE: Record<ConnectionState, string> = {
idle: "bg-muted text-muted-foreground",
connecting: "bg-primary/10 text-primary",
open: "bg-emerald-500/10 text-emerald-500 dark:text-emerald-400",
closed: "bg-muted text-muted-foreground",
error: "bg-destructive/10 text-destructive",
};
interface ChatSidebarProps {
channel: string;
}
export function ChatSidebar({ channel }: ChatSidebarProps) {
// `version` bumps on reconnect; gw is derived so we never call setState
// for it inside an effect (React 19's set-state-in-effect rule). The
// counter is the dependency on purpose — it's not read in the memo body,
// it's the signal that says "rebuild the client".
const [version, setVersion] = useState(0);
// eslint-disable-next-line react-hooks/exhaustive-deps
const gw = useMemo(() => new GatewayClient(), [version]);
const [state, setState] = useState<ConnectionState>("idle");
const [sessionId, setSessionId] = useState<string | null>(null);
const [info, setInfo] = useState<SessionInfo>({});
const [tools, setTools] = useState<ToolEntry[]>([]);
const [modelOpen, setModelOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const offState = gw.onState(setState);
const offSessionInfo = gw.on<SessionInfo>("session.info", (ev) => {
if (ev.session_id) {
setSessionId(ev.session_id);
}
if (ev.payload) {
setInfo((prev) => ({ ...prev, ...ev.payload }));
}
});
const offError = gw.on<{ message?: string }>("error", (ev) => {
const message = ev.payload?.message;
if (message) {
setError(message);
}
});
// Adopt whichever session the gateway hands us. session.create on the
// sidecar is independent of the PTY pane's session by design — we
// only need a sid to drive the model picker's slash.exec calls.
gw.connect()
.then(() => gw.request<{ session_id: string }>("session.create", {}))
.then((created) => {
if (created?.session_id) {
setSessionId(created.session_id);
}
})
.catch((e: Error) => setError(e.message));
return () => {
offState();
offSessionInfo();
offError();
gw.close();
};
}, [gw]);
// Event subscriber WebSocket — receives the rebroadcast of every
// dispatcher emit from the PTY child's gateway. See /api/pub +
// /api/events in hermes_cli/web_server.py for the broadcast hop.
//
// Failures (auth/loopback rejection, server too old to expose the
// endpoint, transient drops) surface in the same banner as the
// JSON-RPC sidecar so the sidebar matches its documented best-effort
// UX and the user always has a reconnect affordance.
useEffect(() => {
const token = window.__HERMES_SESSION_TOKEN__;
if (!token || !channel) {
return;
}
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
const qs = new URLSearchParams({ token, channel });
const ws = new WebSocket(
`${proto}//${window.location.host}/api/events?${qs.toString()}`,
);
// `unmounting` suppresses the banner during cleanup — `ws.close()`
// from the effect's return fires a close event with code 1005 that
// would otherwise look like an unexpected drop.
const DISCONNECTED = "events feed disconnected — tool calls may not appear";
let unmounting = false;
const surface = (msg: string) => !unmounting && setError(msg);
ws.addEventListener("error", () => surface(DISCONNECTED));
ws.addEventListener("close", (ev) => {
if (ev.code === 4401 || ev.code === 4403) {
surface(`events feed rejected (${ev.code}) — reload the page`);
} else if (ev.code !== 1000) {
surface(DISCONNECTED);
}
});
ws.addEventListener("message", (ev) => {
let frame: RpcEnvelope;
try {
frame = JSON.parse(ev.data);
} catch {
return;
}
if (frame.method !== "event" || !frame.params) {
return;
}
const { type, payload } = frame.params;
if (type === "tool.start") {
const p = payload as
| { tool_id?: string; name?: string; context?: string }
| undefined;
const toolId = p?.tool_id;
if (!toolId) {
return;
}
setTools((prev) =>
[
...prev,
{
kind: "tool" as const,
id: `tool-${toolId}-${prev.length}`,
tool_id: toolId,
name: p?.name ?? "tool",
context: p?.context,
status: "running" as const,
startedAt: Date.now(),
},
].slice(-TOOL_LIMIT),
);
} else if (type === "tool.progress") {
const p = payload as
| { name?: string; preview?: string }
| undefined;
if (!p?.name || !p.preview) {
return;
}
setTools((prev) =>
prev.map((t) =>
t.status === "running" && t.name === p.name
? { ...t, preview: p.preview }
: t,
),
);
} else if (type === "tool.complete") {
const p = payload as
| {
tool_id?: string;
summary?: string;
error?: string;
inline_diff?: string;
}
| undefined;
if (!p?.tool_id) {
return;
}
setTools((prev) =>
prev.map((t) =>
t.tool_id === p.tool_id
? {
...t,
status: p.error ? "error" : "done",
summary: p.summary,
error: p.error,
inline_diff: p.inline_diff,
completedAt: Date.now(),
}
: t,
),
);
}
});
return () => {
unmounting = true;
ws.close();
};
}, [channel, version]);
const reconnect = useCallback(() => {
setError(null);
setTools([]);
setVersion((v) => v + 1);
}, []);
// Picker hands us a fully-formed slash command (e.g. "/model anthropic/...").
// Fire-and-forget through `slash.exec`; the TUI pane will render the result
// via PTY, so the sidebar doesn't need to surface output of its own.
const onModelSubmit = useCallback(
(slashCommand: string) => {
if (!sessionId) {
return;
}
void gw.request("slash.exec", {
session_id: sessionId,
command: slashCommand,
});
setModelOpen(false);
},
[gw, sessionId],
);
const canPickModel = state === "open" && !!sessionId;
const modelLabel = (info.model ?? "—").split("/").slice(-1)[0] ?? "—";
const banner = error ?? info.credential_warning ?? null;
return (
<aside className="flex h-full w-80 shrink-0 flex-col gap-3 normal-case">
<Card className="flex items-center justify-between gap-2 px-3 py-2">
<div className="min-w-0">
<div className="text-xs uppercase tracking-wider text-muted-foreground">
model
</div>
<button
type="button"
disabled={!canPickModel}
onClick={() => setModelOpen(true)}
className="flex items-center gap-1 truncate text-sm font-medium hover:underline disabled:cursor-not-allowed disabled:opacity-60 disabled:no-underline"
title={info.model ?? "switch model"}
>
<span className="truncate">{modelLabel}</span>
{canPickModel && (
<ChevronDown className="h-3 w-3 shrink-0 opacity-60" />
)}
</button>
</div>
<Badge className={STATE_TONE[state]}>{STATE_LABEL[state]}</Badge>
</Card>
{banner && (
<Card className="flex items-start gap-2 border-destructive/40 bg-destructive/5 px-3 py-2 text-xs">
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-destructive" />
<div className="min-w-0 flex-1">
<div className="wrap-break-word text-destructive">{banner}</div>
{error && (
<Button
variant="ghost"
size="sm"
className="mt-1 h-6 px-1.5 text-xs"
onClick={reconnect}
>
<RefreshCw className="mr-1 h-3 w-3" />
reconnect
</Button>
)}
</div>
</Card>
)}
<Card className="flex min-h-0 flex-1 flex-col px-2 py-2">
<div className="px-1 pb-2 text-xs uppercase tracking-wider text-muted-foreground">
tools
</div>
<div className="flex min-h-0 flex-1 flex-col gap-1.5 overflow-y-auto pr-1">
{tools.length === 0 ? (
<div className="px-2 py-4 text-center text-xs text-muted-foreground">
no tool calls yet
</div>
) : (
tools.map((t) => <ToolCall key={t.id} tool={t} />)
)}
</div>
</Card>
{modelOpen && canPickModel && sessionId && (
<ModelPickerDialog
gw={gw}
sessionId={sessionId}
onClose={() => setModelOpen(false)}
onSubmit={onModelSubmit}
/>
)}
</aside>
);
}

View file

@ -1,22 +1,50 @@
import { useMemo } from "react"; import { useMemo, type ReactNode } from "react";
/** /**
* Lightweight markdown renderer for LLM output. * Lightweight markdown renderer for LLM output.
* Handles: code blocks, inline code, bold, italic, headers, links, lists, horizontal rules. * Handles: code blocks, inline code, bold, italic, headers, links, lists, horizontal rules.
* NOT a full CommonMark parser optimized for typical assistant message patterns. * NOT a full CommonMark parser optimized for typical assistant message patterns.
*
* `streaming` renders a blinking caret at the tail of the last block so it
* appears to hug the final character instead of wrapping onto a new line
* after a block element (paragraph/list/code/).
*/ */
export function Markdown({ content, highlightTerms }: { content: string; highlightTerms?: string[] }) { export function Markdown({
content,
highlightTerms,
streaming,
}: {
content: string;
highlightTerms?: string[];
streaming?: boolean;
}) {
const blocks = useMemo(() => parseBlocks(content), [content]); const blocks = useMemo(() => parseBlocks(content), [content]);
const caret = streaming ? <StreamingCaret /> : null;
return ( return (
<div className="text-sm text-foreground leading-relaxed space-y-2"> <div className="text-sm text-foreground leading-relaxed space-y-2">
{blocks.map((block, i) => ( {blocks.map((block, i) => (
<Block key={i} block={block} highlightTerms={highlightTerms} /> <Block
key={i}
block={block}
highlightTerms={highlightTerms}
caret={caret && i === blocks.length - 1 ? caret : null}
/>
))} ))}
{blocks.length === 0 && caret}
</div> </div>
); );
} }
function StreamingCaret() {
return (
<span
aria-hidden
className="inline-block w-[0.5em] h-[1em] ml-0.5 align-[-0.15em] bg-foreground/50 animate-pulse"
/>
);
}
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Types */ /* Types */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@ -58,7 +86,11 @@ function parseBlocks(text: string): BlockNode[] {
// Heading // Heading
const headingMatch = line.match(/^(#{1,4})\s+(.+)/); const headingMatch = line.match(/^(#{1,4})\s+(.+)/);
if (headingMatch) { if (headingMatch) {
blocks.push({ type: "heading", level: headingMatch[1].length, content: headingMatch[2] }); blocks.push({
type: "heading",
level: headingMatch[1].length,
content: headingMatch[2],
});
i++; i++;
continue; continue;
} }
@ -124,12 +156,23 @@ function parseBlocks(text: string): BlockNode[] {
/* Block renderer */ /* Block renderer */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
function Block({ block, highlightTerms }: { block: BlockNode; highlightTerms?: string[] }) { function Block({
block,
highlightTerms,
caret,
}: {
block: BlockNode;
highlightTerms?: string[];
caret?: ReactNode;
}) {
switch (block.type) { switch (block.type) {
case "code": case "code":
return ( return (
<pre className="bg-secondary/60 border border-border px-3 py-2.5 text-xs font-mono leading-relaxed overflow-x-auto"> <pre className="bg-secondary/60 border border-border px-3 py-2.5 text-xs font-mono leading-relaxed overflow-x-auto">
<code>{block.content}</code> <code>
{block.content}
{caret}
</code>
</pre> </pre>
); );
@ -141,25 +184,46 @@ function Block({ block, highlightTerms }: { block: BlockNode; highlightTerms?: s
h3: "text-sm font-semibold", h3: "text-sm font-semibold",
h4: "text-sm font-medium", h4: "text-sm font-medium",
}; };
return <Tag className={sizes[Tag]}><InlineContent text={block.content} highlightTerms={highlightTerms} /></Tag>; return (
<Tag className={sizes[Tag]}>
<InlineContent text={block.content} highlightTerms={highlightTerms} />
{caret}
</Tag>
);
} }
case "hr": case "hr":
return <hr className="border-border" />; return (
<>
<hr className="border-border" />
{caret}
</>
);
case "list": { case "list": {
const Tag = block.ordered ? "ol" : "ul"; const Tag = block.ordered ? "ol" : "ul";
const last = block.items.length - 1;
return ( return (
<Tag className={`space-y-0.5 ${block.ordered ? "list-decimal" : "list-disc"} pl-5 text-sm`}> <Tag
className={`space-y-0.5 ${block.ordered ? "list-decimal" : "list-disc"} pl-5 text-sm`}
>
{block.items.map((item, i) => ( {block.items.map((item, i) => (
<li key={i}><InlineContent text={item} highlightTerms={highlightTerms} /></li> <li key={i}>
<InlineContent text={item} highlightTerms={highlightTerms} />
{i === last ? caret : null}
</li>
))} ))}
</Tag> </Tag>
); );
} }
case "paragraph": case "paragraph":
return <p><InlineContent text={block.content} highlightTerms={highlightTerms} /></p>; return (
<p>
<InlineContent text={block.content} highlightTerms={highlightTerms} />
{caret}
</p>
);
} }
} }
@ -178,7 +242,8 @@ type InlineNode =
function parseInline(text: string): InlineNode[] { function parseInline(text: string): InlineNode[] {
const nodes: InlineNode[] = []; const nodes: InlineNode[] = [];
// Pattern priority: code > link > bold > italic > bare URL > line break // Pattern priority: code > link > bold > italic > bare URL > line break
const pattern = /(`[^`]+`)|(\[([^\]]+)\]\(([^)]+)\))|(\*\*([^*]+)\*\*)|(\*([^*]+)\*)|(\bhttps?:\/\/[^\s<>)\]]+)|(\n)/g; const pattern =
/(`[^`]+`)|(\[([^\]]+)\]\(([^)]+)\))|(\*\*([^*]+)\*\*)|(\*([^*]+)\*)|(\bhttps?:\/\/[^\s<>)\]]+)|(\n)/g;
let lastIndex = 0; let lastIndex = 0;
let match: RegExpExecArray | null; let match: RegExpExecArray | null;
@ -217,7 +282,13 @@ function parseInline(text: string): InlineNode[] {
return nodes; return nodes;
} }
function InlineContent({ text, highlightTerms }: { text: string; highlightTerms?: string[] }) { function InlineContent({
text,
highlightTerms,
}: {
text: string;
highlightTerms?: string[];
}) {
const nodes = useMemo(() => parseInline(text), [text]); const nodes = useMemo(() => parseInline(text), [text]);
return ( return (
@ -225,17 +296,34 @@ function InlineContent({ text, highlightTerms }: { text: string; highlightTerms?
{nodes.map((node, i) => { {nodes.map((node, i) => {
switch (node.type) { switch (node.type) {
case "text": case "text":
return <HighlightedText key={i} text={node.content} terms={highlightTerms} />; return (
<HighlightedText
key={i}
text={node.content}
terms={highlightTerms}
/>
);
case "code": case "code":
return ( return (
<code key={i} className="bg-secondary/60 px-1.5 py-0.5 text-xs font-mono text-primary/90"> <code
key={i}
className="bg-secondary/60 px-1.5 py-0.5 text-xs font-mono text-primary/90"
>
{node.content} {node.content}
</code> </code>
); );
case "bold": case "bold":
return <strong key={i} className="font-semibold"><HighlightedText text={node.content} terms={highlightTerms} /></strong>; return (
<strong key={i} className="font-semibold">
<HighlightedText text={node.content} terms={highlightTerms} />
</strong>
);
case "italic": case "italic":
return <em key={i}><HighlightedText text={node.content} terms={highlightTerms} /></em>; return (
<em key={i}>
<HighlightedText text={node.content} terms={highlightTerms} />
</em>
);
case "link": case "link":
return ( return (
<a <a
@ -269,10 +357,12 @@ function HighlightedText({ text, terms }: { text: string; terms?: string[] }) {
<> <>
{parts.map((part, i) => {parts.map((part, i) =>
regex.test(part) ? ( regex.test(part) ? (
<mark key={i} className="bg-warning/30 text-warning px-0.5">{part}</mark> <mark key={i} className="bg-warning/30 text-warning px-0.5">
{part}
</mark>
) : ( ) : (
<span key={i}>{part}</span> <span key={i}>{part}</span>
) ),
)} )}
</> </>
); );

View file

@ -0,0 +1,392 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import type { GatewayClient } from "@/lib/gatewayClient";
import { Check, Loader2, Search, X } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
/**
* Two-stage model picker modal.
*
* Mirrors ui-tui/src/components/modelPicker.tsx:
* Stage 1: pick provider (authenticated providers only)
* Stage 2: pick model within that provider
*
* On confirm, emits `/model <model> --provider <slug> [--global]` through
* the parent callback so ChatPage can dispatch it via the existing slash
* pipeline. That keeps persistence + actual switch logic in one place.
*/
interface ModelOptionProvider {
name: string;
slug: string;
models?: string[];
total_models?: number;
is_current?: boolean;
warning?: string;
}
interface ModelOptionsResponse {
model?: string;
provider?: string;
providers?: ModelOptionProvider[];
}
interface Props {
gw: GatewayClient;
sessionId: string;
onClose(): void;
/** Parent runs the resulting slash command through slashExec. */
onSubmit(slashCommand: string): void;
}
export function ModelPickerDialog({ gw, sessionId, onClose, onSubmit }: Props) {
const [providers, setProviders] = useState<ModelOptionProvider[]>([]);
const [currentModel, setCurrentModel] = useState("");
const [currentProviderSlug, setCurrentProviderSlug] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedSlug, setSelectedSlug] = useState("");
const [selectedModel, setSelectedModel] = useState("");
const [query, setQuery] = useState("");
const [persistGlobal, setPersistGlobal] = useState(false);
const closedRef = useRef(false);
// Load providers + models on open.
useEffect(() => {
closedRef.current = false;
gw.request<ModelOptionsResponse>(
"model.options",
sessionId ? { session_id: sessionId } : {},
)
.then((r) => {
if (closedRef.current) return;
const next = r?.providers ?? [];
setProviders(next);
setCurrentModel(String(r?.model ?? ""));
setCurrentProviderSlug(String(r?.provider ?? ""));
setSelectedSlug(
(next.find((p) => p.is_current) ?? next[0])?.slug ?? "",
);
setSelectedModel("");
setLoading(false);
})
.catch((e) => {
if (closedRef.current) return;
setError(e instanceof Error ? e.message : String(e));
setLoading(false);
});
return () => {
closedRef.current = true;
};
}, [gw, sessionId]);
// Esc closes.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onClose();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const selectedProvider = useMemo(
() => providers.find((p) => p.slug === selectedSlug) ?? null,
[providers, selectedSlug],
);
const models = useMemo(
() => selectedProvider?.models ?? [],
[selectedProvider],
);
const needle = query.trim().toLowerCase();
const filteredProviders = useMemo(
() =>
!needle
? providers
: providers.filter(
(p) =>
p.name.toLowerCase().includes(needle) ||
p.slug.toLowerCase().includes(needle) ||
(p.models ?? []).some((m) => m.toLowerCase().includes(needle)),
),
[providers, needle],
);
const filteredModels = useMemo(
() =>
!needle ? models : models.filter((m) => m.toLowerCase().includes(needle)),
[models, needle],
);
const canConfirm = !!selectedProvider && !!selectedModel;
const confirm = () => {
if (!canConfirm) return;
const global = persistGlobal ? " --global" : "";
onSubmit(
`/model ${selectedModel} --provider ${selectedProvider.slug}${global}`,
);
onClose();
};
return (
<div
className="fixed inset-0 z-100 flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
onClick={(e) => e.target === e.currentTarget && onClose()}
role="dialog"
aria-modal="true"
aria-labelledby="model-picker-title"
>
<div className="relative w-full max-w-3xl max-h-[80vh] border border-border bg-card shadow-2xl flex flex-col">
<button
type="button"
onClick={onClose}
className="absolute right-3 top-3 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
aria-label="Close"
>
<X className="h-5 w-5" />
</button>
<header className="p-5 pb-3 border-b border-border">
<h2
id="model-picker-title"
className="font-display text-base tracking-wider uppercase"
>
Switch Model
</h2>
<p className="text-xs text-muted-foreground mt-1 font-mono">
current: {currentModel || "(unknown)"}
{currentProviderSlug && ` · ${currentProviderSlug}`}
</p>
</header>
<div className="px-5 pt-3 pb-2 border-b border-border">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
autoFocus
placeholder="Filter providers and models…"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-7 h-8 text-sm"
/>
</div>
</div>
<div className="flex-1 min-h-0 grid grid-cols-[200px_1fr] overflow-hidden">
<ProviderColumn
loading={loading}
error={error}
providers={filteredProviders}
total={providers.length}
selectedSlug={selectedSlug}
query={needle}
onSelect={(slug) => {
setSelectedSlug(slug);
setSelectedModel("");
}}
/>
<ModelColumn
provider={selectedProvider}
models={filteredModels}
allModels={models}
selectedModel={selectedModel}
currentModel={currentModel}
currentProviderSlug={currentProviderSlug}
onSelect={setSelectedModel}
onConfirm={(m) => {
setSelectedModel(m);
// Confirm on next tick so state settles.
window.setTimeout(confirm, 0);
}}
/>
</div>
<footer className="border-t border-border p-3 flex items-center justify-between gap-3 flex-wrap">
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer select-none">
<input
type="checkbox"
checked={persistGlobal}
onChange={(e) => setPersistGlobal(e.target.checked)}
className="cursor-pointer"
/>
Persist globally (otherwise this session only)
</label>
<div className="flex items-center gap-2 ml-auto">
<Button variant="ghost" size="sm" onClick={onClose}>
Cancel
</Button>
<Button size="sm" onClick={confirm} disabled={!canConfirm}>
Switch
</Button>
</div>
</footer>
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Provider column */
/* ------------------------------------------------------------------ */
function ProviderColumn({
loading,
error,
providers,
total,
selectedSlug,
query,
onSelect,
}: {
loading: boolean;
error: string | null;
providers: ModelOptionProvider[];
total: number;
selectedSlug: string;
query: string;
onSelect(slug: string): void;
}) {
return (
<div className="border-r border-border overflow-y-auto">
{loading && (
<div className="flex items-center gap-2 p-4 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" /> loading
</div>
)}
{error && <div className="p-4 text-xs text-destructive">{error}</div>}
{!loading && !error && providers.length === 0 && (
<div className="p-4 text-xs text-muted-foreground italic">
{query
? "no matches"
: total === 0
? "no authenticated providers"
: "no matches"}
</div>
)}
{providers.map((p) => {
const active = p.slug === selectedSlug;
return (
<button
key={p.slug}
type="button"
onClick={() => onSelect(p.slug)}
className={`w-full text-left px-3 py-2 text-xs border-l-2 transition-colors cursor-pointer flex items-start gap-2 ${
active
? "bg-primary/10 border-l-primary text-foreground"
: "border-l-transparent text-muted-foreground hover:text-foreground hover:bg-muted/40"
}`}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="font-medium truncate">{p.name}</span>
{p.is_current && <CurrentTag />}
</div>
<div className="text-[0.65rem] text-muted-foreground/80 font-mono truncate">
{p.slug} · {p.total_models ?? p.models?.length ?? 0} models
</div>
</div>
</button>
);
})}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Model column */
/* ------------------------------------------------------------------ */
function ModelColumn({
provider,
models,
allModels,
selectedModel,
currentModel,
currentProviderSlug,
onSelect,
onConfirm,
}: {
provider: ModelOptionProvider | null;
models: string[];
allModels: string[];
selectedModel: string;
currentModel: string;
currentProviderSlug: string;
onSelect(model: string): void;
onConfirm(model: string): void;
}) {
if (!provider) {
return (
<div className="overflow-y-auto">
<div className="p-4 text-xs text-muted-foreground italic">
pick a provider
</div>
</div>
);
}
return (
<div className="overflow-y-auto">
{provider.warning && (
<div className="p-3 text-xs text-destructive border-b border-border">
{provider.warning}
</div>
)}
{models.length === 0 ? (
<div className="p-4 text-xs text-muted-foreground italic">
{allModels.length
? "no models match your filter"
: "no models listed for this provider"}
</div>
) : (
models.map((m) => {
const active = m === selectedModel;
const isCurrent =
m === currentModel && provider.slug === currentProviderSlug;
return (
<button
key={m}
type="button"
onClick={() => onSelect(m)}
onDoubleClick={() => onConfirm(m)}
className={`w-full text-left px-3 py-1.5 text-xs font-mono transition-colors cursor-pointer flex items-center gap-2 ${
active
? "bg-primary/15 text-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
}`}
>
<Check
className={`h-3 w-3 shrink-0 ${active ? "text-primary" : "text-transparent"}`}
/>
<span className="flex-1 truncate">{m}</span>
{isCurrent && <CurrentTag />}
</button>
);
})
)}
</div>
);
}
function CurrentTag() {
return (
<span className="text-[0.6rem] uppercase tracking-wider text-primary/80 shrink-0">
current
</span>
);
}

View file

@ -0,0 +1,174 @@
import type { GatewayClient } from "@/lib/gatewayClient";
import { ChevronRight } from "lucide-react";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
/**
* Slash-command autocomplete popover, rendered above the composer in ChatPage.
* Mirrors the completion UX of the Ink TUI type `/`, see matching commands,
* arrow keys or click to select, Tab to apply, Enter to submit.
*
* The parent owns all keyboard handling via `ref.handleKey`, which returns
* true when the popover consumed the event, so the composer's Enter/arrow
* logic stays in one place.
*/
export interface CompletionItem {
display: string;
text: string;
meta?: string;
}
export interface SlashPopoverHandle {
/** Returns true if the key was consumed by the popover. */
handleKey(e: React.KeyboardEvent<HTMLTextAreaElement>): boolean;
}
interface Props {
input: string;
gw: GatewayClient | null;
onApply(nextInput: string): void;
}
interface CompletionResponse {
items?: CompletionItem[];
replace_from?: number;
}
const DEBOUNCE_MS = 60;
export const SlashPopover = forwardRef<SlashPopoverHandle, Props>(
function SlashPopover({ input, gw, onApply }, ref) {
const [items, setItems] = useState<CompletionItem[]>([]);
const [selected, setSelected] = useState(0);
const [replaceFrom, setReplaceFrom] = useState(1);
const lastInputRef = useRef<string>("");
// Debounced completion fetch. We never clear `items` in the effect body
// (doing so would flag react-hooks/set-state-in-effect); instead the
// render guard below hides stale items once the input stops matching.
useEffect(() => {
const trimmed = input ?? "";
if (!gw || !trimmed.startsWith("/") || trimmed === lastInputRef.current) {
if (!trimmed.startsWith("/")) lastInputRef.current = "";
return;
}
lastInputRef.current = trimmed;
const timer = window.setTimeout(async () => {
if (lastInputRef.current !== trimmed) return;
try {
const r = await gw.request<CompletionResponse>("complete.slash", {
text: trimmed,
});
if (lastInputRef.current !== trimmed) return;
setItems(r?.items ?? []);
setReplaceFrom(r?.replace_from ?? 1);
setSelected(0);
} catch {
if (lastInputRef.current === trimmed) setItems([]);
}
}, DEBOUNCE_MS);
return () => window.clearTimeout(timer);
}, [input, gw]);
const apply = useCallback(
(item: CompletionItem) => {
onApply(input.slice(0, replaceFrom) + item.text);
},
[input, replaceFrom, onApply],
);
// Only consume keys when the popover is actually visible. Stale items from
// a previous slash prefix are ignored once the user deletes the "/".
const visible = items.length > 0 && input.startsWith("/");
useImperativeHandle(
ref,
() => ({
handleKey: (e) => {
if (!visible) return false;
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setSelected((s) => (s + 1) % items.length);
return true;
case "ArrowUp":
e.preventDefault();
setSelected((s) => (s - 1 + items.length) % items.length);
return true;
case "Tab": {
e.preventDefault();
const item = items[selected];
if (item) apply(item);
return true;
}
case "Escape":
e.preventDefault();
setItems([]);
return true;
default:
return false;
}
},
}),
[visible, items, selected, apply],
);
if (!visible) return null;
return (
<div
className="absolute bottom-full left-0 right-0 mb-2 max-h-64 overflow-y-auto rounded-md border border-border bg-popover shadow-xl text-sm"
role="listbox"
>
{items.map((it, i) => {
const active = i === selected;
return (
<button
key={`${it.text}-${i}`}
type="button"
role="option"
aria-selected={active}
onMouseEnter={() => setSelected(i)}
onClick={() => apply(it)}
className={`w-full flex items-center gap-2 px-3 py-1.5 text-left cursor-pointer transition-colors ${
active
? "bg-primary/10 text-foreground"
: "text-muted-foreground hover:bg-muted/60"
}`}
>
<ChevronRight
className={`h-3 w-3 shrink-0 ${active ? "text-primary" : "text-transparent"}`}
/>
<span className="font-mono text-xs shrink-0 truncate">
{it.display}
</span>
{it.meta && (
<span className="text-[0.7rem] text-muted-foreground/70 truncate ml-auto">
{it.meta}
</span>
)}
</button>
);
})}
</div>
);
},
);

View file

@ -0,0 +1,228 @@
import {
AlertCircle,
Check,
ChevronDown,
ChevronRight,
Zap,
} from "lucide-react";
import { useEffect, useState } from "react";
/**
* Expandable tool call row the web equivalent of Ink's ToolTrail node.
*
* Renders one `tool.start` + `tool.complete` pair (plus any `tool.progress`
* in between) as a single collapsible item in the transcript:
*
* read_file(path=/foo) 2.3s
*
* Click the header to reveal a preformatted body with context (args), the
* streaming preview (while running), and the final summary or error. Error
* rows auto-expand so failures aren't silently collapsed.
*/
export interface ToolEntry {
kind: "tool";
id: string;
tool_id: string;
name: string;
context?: string;
preview?: string;
summary?: string;
error?: string;
inline_diff?: string;
status: "running" | "done" | "error";
startedAt: number;
completedAt?: number;
}
const STATUS_TONE: Record<ToolEntry["status"], string> = {
running: "border-primary/40 bg-primary/[0.04]",
done: "border-border bg-muted/20",
error: "border-destructive/50 bg-destructive/[0.04]",
};
const BULLET_TONE: Record<ToolEntry["status"], string> = {
running: "text-primary",
done: "text-primary/80",
error: "text-destructive",
};
const TICK_MS = 500;
export function ToolCall({ tool }: { tool: ToolEntry }) {
// `open` is derived: errors default-expanded, everything else collapsed.
// `null` means "follow the default"; any explicit bool is the user's override.
// This lets a running tool flip to expanded automatically when it errors,
// without mirroring state in an effect.
const [userOverride, setUserOverride] = useState<boolean | null>(null);
const open = userOverride ?? tool.status === "error";
// Tick `now` while the tool is running so the elapsed label updates live.
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (tool.status !== "running") return;
const id = window.setInterval(() => setNow(() => Date.now()), TICK_MS);
return () => window.clearInterval(id);
}, [tool.status]);
// Historical tools (hydrated from session.resume) signal missing timestamps
// with `startedAt === 0`; we hide the elapsed badge for those rather than
// rendering a misleading "0ms".
const hasTimestamps = tool.startedAt > 0;
const elapsed = hasTimestamps
? fmtElapsed((tool.completedAt ?? now) - tool.startedAt)
: null;
const hasBody = !!(
tool.context ||
tool.preview ||
tool.summary ||
tool.error ||
tool.inline_diff
);
const Chevron = open ? ChevronDown : ChevronRight;
return (
<div
className={`rounded-md border overflow-hidden ${STATUS_TONE[tool.status]}`}
>
<button
type="button"
onClick={() => setUserOverride(!open)}
disabled={!hasBody}
aria-expanded={open}
className="w-full flex items-center gap-2 px-2.5 py-1.5 text-left text-xs hover:bg-foreground/2 disabled:cursor-default cursor-pointer transition-colors"
>
{hasBody ? (
<Chevron className="h-3 w-3 shrink-0 text-muted-foreground" />
) : (
<span className="w-3 shrink-0" />
)}
<Zap className={`h-3 w-3 shrink-0 ${BULLET_TONE[tool.status]}`} />
<span className="font-mono font-medium shrink-0">{tool.name}</span>
<span className="font-mono text-muted-foreground/80 truncate min-w-0 flex-1">
{tool.context ?? ""}
</span>
{tool.status === "running" && (
<span
className="inline-block h-2 w-2 rounded-full bg-primary animate-pulse shrink-0"
title="running"
/>
)}
{tool.status === "error" && (
<AlertCircle
className="h-3 w-3 shrink-0 text-destructive"
aria-label="error"
/>
)}
{tool.status === "done" && (
<Check
className="h-3 w-3 shrink-0 text-primary/80"
aria-label="done"
/>
)}
{elapsed && (
<span className="font-mono text-[0.65rem] text-muted-foreground tabular-nums shrink-0">
{elapsed}
</span>
)}
</button>
{open && hasBody && (
<div className="border-t border-border/60 px-3 py-2 space-y-2 text-xs font-mono">
{tool.context && <Section label="context">{tool.context}</Section>}
{tool.preview && tool.status === "running" && (
<Section label="streaming">
{tool.preview}
<span className="inline-block w-1.5 h-3 align-middle bg-foreground/40 ml-0.5 animate-pulse" />
</Section>
)}
{tool.inline_diff && (
<Section label="diff">
<pre className="whitespace-pre overflow-x-auto text-[0.7rem] leading-snug">
{colorizeDiff(tool.inline_diff)}
</pre>
</Section>
)}
{tool.summary && (
<Section label="result">
<span className="text-foreground/90 whitespace-pre-wrap">
{tool.summary}
</span>
</Section>
)}
{tool.error && (
<Section label="error" tone="error">
<span className="text-destructive whitespace-pre-wrap">
{tool.error}
</span>
</Section>
)}
</div>
)}
</div>
);
}
function Section({
label,
children,
tone,
}: {
label: string;
children: React.ReactNode;
tone?: "error";
}) {
return (
<div className="flex gap-3">
<span
className={`uppercase tracking-wider text-[0.6rem] shrink-0 w-14 pt-0.5 ${
tone === "error" ? "text-destructive/80" : "text-muted-foreground/60"
}`}
>
{label}
</span>
<div className="flex-1 min-w-0 text-muted-foreground">{children}</div>
</div>
);
}
function fmtElapsed(ms: number): string {
const sec = Math.max(0, ms) / 1000;
if (sec < 1) return `${Math.round(ms)}ms`;
if (sec < 10) return `${sec.toFixed(1)}s`;
if (sec < 60) return `${Math.round(sec)}s`;
const m = Math.floor(sec / 60);
const s = Math.round(sec % 60);
return s ? `${m}m ${s}s` : `${m}m`;
}
/** Colorize unified-diff lines for the inline diff section. */
function colorizeDiff(diff: string): React.ReactNode {
return diff.split("\n").map((line, i) => (
<div key={i} className={diffLineClass(line)}>
{line || "\u00A0"}
</div>
));
}
function diffLineClass(line: string): string {
if (line.startsWith("+") && !line.startsWith("+++"))
return "text-emerald-500 dark:text-emerald-400";
if (line.startsWith("-") && !line.startsWith("---"))
return "text-destructive";
if (line.startsWith("@@")) return "text-primary";
return "text-muted-foreground/80";
}

View file

@ -67,6 +67,7 @@ export const en: Translations = {
}, },
nav: { nav: {
analytics: "Analytics", analytics: "Analytics",
chat: "Chat",
config: "Config", config: "Config",
cron: "Cron", cron: "Cron",
documentation: "Documentation", documentation: "Documentation",
@ -131,6 +132,7 @@ export const en: Translations = {
"This permanently removes the conversation and all of its messages. This cannot be undone.", "This permanently removes the conversation and all of its messages. This cannot be undone.",
sessionDeleted: "Session deleted", sessionDeleted: "Session deleted",
failedToDelete: "Failed to delete session", failedToDelete: "Failed to delete session",
resumeInChat: "Resume in Chat",
previousPage: "Previous page", previousPage: "Previous page",
nextPage: "Next page", nextPage: "Next page",
roles: { roles: {

View file

@ -67,6 +67,7 @@ export interface Translations {
}; };
nav: { nav: {
analytics: string; analytics: string;
chat: string;
config: string; config: string;
cron: string; cron: string;
documentation: string; documentation: string;
@ -132,6 +133,7 @@ export interface Translations {
confirmDeleteMessage: string; confirmDeleteMessage: string;
sessionDeleted: string; sessionDeleted: string;
failedToDelete: string; failedToDelete: string;
resumeInChat: string;
previousPage: string; previousPage: string;
nextPage: string; nextPage: string;
roles: { roles: {

View file

@ -66,6 +66,7 @@ export const zh: Translations = {
}, },
nav: { nav: {
analytics: "分析", analytics: "分析",
chat: "对话",
config: "配置", config: "配置",
cron: "定时任务", cron: "定时任务",
documentation: "文档", documentation: "文档",
@ -129,6 +130,7 @@ export const zh: Translations = {
confirmDeleteMessage: "此操作将永久删除对话及其所有消息,无法恢复。", confirmDeleteMessage: "此操作将永久删除对话及其所有消息,无法恢复。",
sessionDeleted: "会话已删除", sessionDeleted: "会话已删除",
failedToDelete: "删除会话失败", failedToDelete: "删除会话失败",
resumeInChat: "在对话中继续",
previousPage: "上一页", previousPage: "上一页",
nextPage: "下一页", nextPage: "下一页",
roles: { roles: {

View file

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

View file

@ -0,0 +1,236 @@
/**
* Browser WebSocket client for the tui_gateway JSON-RPC protocol.
*
* Speaks the exact same newline-delimited JSON-RPC dialect that the Ink TUI
* drives over stdio. The server-side transport abstraction
* (tui_gateway/transport.py + ws.py) routes the same dispatcher's writes
* onto either stdout or a WebSocket depending on how the client connected.
*
* const gw = new GatewayClient()
* await gw.connect()
* const { session_id } = await gw.request<{ session_id: string }>("session.create")
* gw.on("message.delta", (ev) => console.log(ev.payload?.text))
* await gw.request("prompt.submit", { session_id, text: "hi" })
*/
export type GatewayEventName =
| "gateway.ready"
| "session.info"
| "message.start"
| "message.delta"
| "message.complete"
| "thinking.delta"
| "reasoning.delta"
| "reasoning.available"
| "status.update"
| "tool.start"
| "tool.progress"
| "tool.complete"
| "tool.generating"
| "clarify.request"
| "approval.request"
| "sudo.request"
| "secret.request"
| "background.complete"
| "btw.complete"
| "error"
| "skin.changed"
| (string & {});
export interface GatewayEvent<P = unknown> {
type: GatewayEventName;
session_id?: string;
payload?: P;
}
export type ConnectionState =
| "idle"
| "connecting"
| "open"
| "closed"
| "error";
interface Pending {
resolve: (v: unknown) => void;
reject: (e: Error) => void;
timer: ReturnType<typeof setTimeout>;
}
const DEFAULT_REQUEST_TIMEOUT_MS = 120_000;
/** Wildcard listener key: subscribe to every event regardless of type. */
const ANY = "*";
export class GatewayClient {
private ws: WebSocket | null = null;
private reqId = 0;
private pending = new Map<string, Pending>();
private listeners = new Map<string, Set<(ev: GatewayEvent) => void>>();
private _state: ConnectionState = "idle";
private stateListeners = new Set<(s: ConnectionState) => void>();
get state(): ConnectionState {
return this._state;
}
private setState(s: ConnectionState) {
if (this._state === s) return;
this._state = s;
for (const cb of this.stateListeners) cb(s);
}
onState(cb: (s: ConnectionState) => void): () => void {
this.stateListeners.add(cb);
cb(this._state);
return () => this.stateListeners.delete(cb);
}
/** Subscribe to a specific event type. Returns an unsubscribe function. */
on<P = unknown>(
type: GatewayEventName,
cb: (ev: GatewayEvent<P>) => void,
): () => void {
let set = this.listeners.get(type);
if (!set) {
set = new Set();
this.listeners.set(type, set);
}
set.add(cb as (ev: GatewayEvent) => void);
return () => set!.delete(cb as (ev: GatewayEvent) => void);
}
/** Subscribe to every event (fires after type-specific listeners). */
onAny(cb: (ev: GatewayEvent) => void): () => void {
return this.on(ANY as GatewayEventName, cb);
}
async connect(token?: string): Promise<void> {
if (this._state === "open" || this._state === "connecting") return;
this.setState("connecting");
const resolved = token ?? window.__HERMES_SESSION_TOKEN__ ?? "";
if (!resolved) {
this.setState("error");
throw new Error(
"Session token not available — page must be served by the Hermes dashboard",
);
}
const scheme = location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(
`${scheme}//${location.host}/api/ws?token=${encodeURIComponent(resolved)}`,
);
this.ws = ws;
// Register message + close BEFORE awaiting open — the server emits
// `gateway.ready` immediately after accept, so a listener attached
// after the open promise resolves can race past it and drop the
// initial skin payload.
ws.addEventListener("message", (ev) => {
try {
this.dispatch(JSON.parse(ev.data));
} catch {
/* malformed frame — ignore */
}
});
ws.addEventListener("close", () => {
this.setState("closed");
this.rejectAllPending(new Error("WebSocket closed"));
});
await new Promise<void>((resolve, reject) => {
const onOpen = () => {
ws.removeEventListener("error", onError);
this.setState("open");
resolve();
};
const onError = () => {
ws.removeEventListener("open", onOpen);
this.setState("error");
reject(new Error("WebSocket connection failed"));
};
ws.addEventListener("open", onOpen, { once: true });
ws.addEventListener("error", onError, { once: true });
});
}
close() {
this.ws?.close();
this.ws = null;
}
private dispatch(msg: Record<string, unknown>) {
const id = msg.id as string | undefined;
if (id !== undefined && this.pending.has(id)) {
const p = this.pending.get(id)!;
this.pending.delete(id);
clearTimeout(p.timer);
const err = msg.error as { message?: string } | undefined;
if (err) p.reject(new Error(err.message ?? "request failed"));
else p.resolve(msg.result);
return;
}
if (msg.method !== "event") return;
const params = (msg.params ?? {}) as GatewayEvent;
if (typeof params.type !== "string") return;
for (const cb of this.listeners.get(params.type) ?? []) cb(params);
for (const cb of this.listeners.get(ANY) ?? []) cb(params);
}
private rejectAllPending(err: Error) {
for (const p of this.pending.values()) {
clearTimeout(p.timer);
p.reject(err);
}
this.pending.clear();
}
/** Send a JSON-RPC request. Rejects on error response or timeout. */
request<T = unknown>(
method: string,
params: Record<string, unknown> = {},
timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
): Promise<T> {
if (!this.ws || this._state !== "open") {
return Promise.reject(
new Error(`gateway not connected (state=${this._state})`),
);
}
const id = `w${++this.reqId}`;
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
if (this.pending.delete(id)) {
reject(new Error(`request timed out: ${method}`));
}
}, timeoutMs);
this.pending.set(id, {
resolve: (v) => resolve(v as T),
reject,
timer,
});
try {
this.ws!.send(JSON.stringify({ jsonrpc: "2.0", id, method, params }));
} catch (e) {
clearTimeout(timer);
this.pending.delete(id);
reject(e instanceof Error ? e : new Error(String(e)));
}
});
}
}
declare global {
interface Window {
__HERMES_SESSION_TOKEN__?: string;
}
}

View file

@ -1,6 +1,7 @@
import type { Translations } from "@/i18n/types"; import type { Translations } from "@/i18n/types";
const BUILTIN: Record<string, keyof Translations["app"]["nav"]> = { const BUILTIN: Record<string, keyof Translations["app"]["nav"]> = {
"/chat": "chat",
"/sessions": "sessions", "/sessions": "sessions",
"/analytics": "analytics", "/analytics": "analytics",
"/logs": "logs", "/logs": "logs",

163
web/src/lib/slashExec.ts Normal file
View file

@ -0,0 +1,163 @@
/**
* Slash command execution pipeline for the web chat.
*
* Mirrors the Ink TUI's createSlashHandler.ts:
*
* 1. Parse the command into `name` + `arg`.
* 2. Try `slash.exec` covers every registry-backed command the terminal
* UI knows about (/help, /resume, /compact, /model, ). Output is
* rendered into the transcript.
* 3. If `slash.exec` errors (command rejected, unknown, or needs client
* behaviour), fall back to `command.dispatch` which returns a typed
* directive: `exec` | `plugin` | `alias` | `skill` | `send`.
* 4. Each directive is dispatched to the appropriate callback.
*
* Keeping the pipeline here (instead of inline in ChatPage) lets future
* clients (SwiftUI, Android) implement the same logic by reading the same
* contract.
*/
import type { GatewayClient } from "@/lib/gatewayClient";
export interface SlashExecResponse {
output?: string;
warning?: string;
}
export type CommandDispatchResponse =
| { type: "exec" | "plugin"; output?: string }
| { type: "alias"; target: string }
| { type: "skill"; name: string; message?: string }
| { type: "send"; message: string };
export interface SlashExecCallbacks {
/** Render a transcript system message. */
sys(text: string): void;
/** Submit a user message to the agent (prompt.submit). */
send(message: string): Promise<void> | void;
}
export interface SlashExecOptions {
/** Raw command including the leading slash (e.g. "/model opus-4.6"). */
command: string;
/** Session id. If empty the call is still issued — some commands are session-less. */
sessionId: string;
gw: GatewayClient;
callbacks: SlashExecCallbacks;
}
export type SlashExecResult = "done" | "sent" | "error";
/**
* Run a slash command. Returns the terminal state so callers can decide
* whether to clear the composer, queue retries, etc.
*/
export async function executeSlash({
command,
sessionId,
gw,
callbacks: { sys, send },
}: SlashExecOptions): Promise<SlashExecResult> {
const { name, arg } = parseSlash(command);
if (!name) {
sys("empty slash command");
return "error";
}
// Primary dispatcher.
try {
const r = await gw.request<SlashExecResponse>("slash.exec", {
command: command.replace(/^\/+/, ""),
session_id: sessionId,
});
const body = r?.output || `/${name}: no output`;
sys(r?.warning ? `warning: ${r.warning}\n${body}` : body);
return "done";
} catch {
/* fall through to command.dispatch */
}
try {
const d = parseCommandDispatch(
await gw.request<unknown>("command.dispatch", {
name,
arg,
session_id: sessionId,
}),
);
if (!d) {
sys("error: invalid response: command.dispatch");
return "error";
}
switch (d.type) {
case "exec":
case "plugin":
sys(d.output ?? "(no output)");
return "done";
case "alias":
return executeSlash({
command: `/${d.target}${arg ? ` ${arg}` : ""}`,
sessionId,
gw,
callbacks: { sys, send },
});
case "skill":
case "send": {
const msg = d.message?.trim() ?? "";
if (!msg) {
sys(
`/${name}: ${d.type === "skill" ? "skill payload missing message" : "empty message"}`,
);
return "error";
}
if (d.type === "skill") sys(`⚡ loading skill: ${d.name}`);
await send(msg);
return "sent";
}
}
} catch (err) {
sys(`error: ${err instanceof Error ? err.message : String(err)}`);
return "error";
}
}
export function parseSlash(command: string): { name: string; arg: string } {
const m = command.replace(/^\/+/, "").match(/^(\S+)\s*(.*)$/);
return m ? { name: m[1], arg: m[2].trim() } : { name: "", arg: "" };
}
function parseCommandDispatch(raw: unknown): CommandDispatchResponse | null {
if (!raw || typeof raw !== "object") return null;
const r = raw as Record<string, unknown>;
const str = (v: unknown) => (typeof v === "string" ? v : undefined);
switch (r.type) {
case "exec":
case "plugin":
return { type: r.type, output: str(r.output) };
case "alias":
return typeof r.target === "string"
? { type: "alias", target: r.target }
: null;
case "skill":
return typeof r.name === "string"
? { type: "skill", name: r.name, message: str(r.message) }
: null;
case "send":
return typeof r.message === "string"
? { type: "send", message: r.message }
: null;
default:
return null;
}
}

474
web/src/pages/ChatPage.tsx Normal file
View file

@ -0,0 +1,474 @@
/**
* 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 { FitAddon } from "@xterm/addon-fit";
import { Unicode11Addon } from "@xterm/addon-unicode11";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { WebglAddon } from "@xterm/addon-webgl";
import { Terminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css";
import { Copy } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { ChatSidebar } from "@/components/ChatSidebar";
function buildWsUrl(
token: string,
resume: string | null,
channel: string,
): string {
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
const qs = new URLSearchParams({ token, channel });
if (resume) qs.set("resume", resume);
return `${proto}//${window.location.host}/api/pty?${qs.toString()}`;
}
// Channel id ties this chat tab's PTY child (publisher) to its sidebar
// (subscriber). Generated once per mount so a tab refresh starts a fresh
// channel — the previous PTY child terminates with the old WS, and its
// channel auto-evicts when no subscribers remain.
function generateChannelId(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `chat-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}`;
}
// 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();
// Lazy-init: the missing-token check happens at construction so the effect
// body doesn't have to setState (React 19's set-state-in-effect rule).
const [banner, setBanner] = useState<string | null>(() =>
typeof window !== "undefined" && !window.__HERMES_SESSION_TOKEN__
? "Session token unavailable. Open this page through `hermes dashboard`, not directly."
: 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 channel = useMemo(() => generateChannelId(), []);
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__;
// Banner already initialised above; just bail before wiring xterm/WS.
if (!token) {
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, channel);
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.
// eslint-disable-next-line no-control-regex -- intentional ESC byte in xterm SGR mouse report parser
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;
}
};
}, [channel]);
// Layout:
// outer flex column — sits inside the dashboard's content area
// row split — terminal pane (flex-1) + sidebar (fixed width, lg+)
// 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.
// sidebar — ChatSidebar opens its own JSON-RPC sidecar; renders
// model badge, tool-call list, model picker. Best-effort: if the
// sidecar fails to connect the terminal pane keeps working.
//
// `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="flex min-h-0 flex-1 gap-3">
<div
className="relative min-w-0 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 className="hidden min-h-0 lg:block">
<ChatSidebar channel={channel} />
</div>
</div>
</div>
);
}
declare global {
interface Window {
__HERMES_SESSION_TOKEN__?: string;
}
}

View file

@ -1,4 +1,11 @@
import { useEffect, useLayoutEffect, useState, useCallback, useRef } from "react"; import {
useEffect,
useLayoutEffect,
useState,
useCallback,
useRef,
} from "react";
import { useNavigate } from "react-router-dom";
import { import {
AlertTriangle, AlertTriangle,
CheckCircle2, CheckCircle2,
@ -16,6 +23,7 @@ import {
MessageCircle, MessageCircle,
Hash, Hash,
X, X,
Play,
} from "lucide-react"; } from "lucide-react";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import type { import type {
@ -262,6 +270,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) {
@ -341,6 +350,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"

View file

@ -64,7 +64,10 @@ export default defineConfig({
}, },
server: { server: {
proxy: { proxy: {
"/api": BACKEND, "/api": {
target: BACKEND,
ws: true,
},
// Same host as `hermes dashboard` must serve these; Vite has no // Same host as `hermes dashboard` must serve these; Vite has no
// dashboard-plugins/* files, so without this, plugin scripts 404 // dashboard-plugins/* files, so without this, plugin scripts 404
// or receive index.html in dev. // or receive index.html in dev.

View file

@ -37,13 +37,13 @@ hermes dashboard --no-open
## Prerequisites ## Prerequisites
The web dashboard requires FastAPI and Uvicorn. Install them with: The web dashboard requires FastAPI and Uvicorn. The Chat tab additionally needs `ptyprocess` to spawn the embedded TUI behind a pseudo-terminal. Install both with:
```bash ```bash
pip install hermes-agent[web] pip install 'hermes-agent[web,pty]'
``` ```
If you installed with `pip install hermes-agent[all]`, the web dependencies are already included. The `web` extra pulls in FastAPI/Uvicorn; `pty` pulls in `ptyprocess` (POSIX) or `pywinpty` (native Windows — note that the embedded TUI itself still requires WSL). `pip install hermes-agent[all]` includes both extras and is the easiest path if you also want messaging/voice/etc.
When you run `hermes dashboard` without the dependencies, it will tell you what to install. If the frontend hasn't been built yet and `npm` is available, it builds automatically on first launch. When you run `hermes dashboard` without the dependencies, it will tell you what to install. If the frontend hasn't been built yet and `npm` is available, it builds automatically on first launch.
@ -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` — installed by the `pty` extra (`pip install 'hermes-agent[web,pty]'`, or `[all]` covers both)
- 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: