From 25ba6783b8eeca3936e10e31c5b5ac9e46a06b09 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 19 Apr 2026 17:29:51 -0500 Subject: [PATCH] feat(tui-gateway): WebSocket transport + /chat web UI, wire-compatible with Ink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts the JSON-RPC transport from stdio into an abstraction so the same dispatcher drives Ink over stdio AND browser/iOS clients over WebSocket without duplicating handler logic. Adds a Chat page to the existing web dashboard that exercises the full surface — streaming, tool calls, slash commands, model picker, session resume. Backend ------- * tui_gateway/transport.py — Transport protocol + contextvar binding + the module-level StdioTransport. Stream is resolved through a callback so tests that monkeypatch `_real_stdout` keep working. * tui_gateway/server.py — write_json and dispatch are now transport-aware. Backward compatible: no transport bound = legacy stdio path, so entry.py (Ink's stdio entrypoint) is unchanged externally. * tui_gateway/ws.py — WSTransport + handle_ws coroutine. Safe to call from any thread: detects loop-thread deadlock and fire-and-forget schedules when needed, blocking run_coroutine_threadsafe + future.result otherwise. * hermes_cli/web_server.py — mounts /api/ws on the existing FastAPI app, gated by the same ephemeral session token used for REST. Adds HERMES_DASHBOARD_DEV_TOKEN env override so Vite HMR dev can share the token with the backend. Frontend -------- * web/src/lib/gatewayClient.ts — browser WebSocket JSON-RPC client that mirrors ui-tui/src/gatewayClient.ts. * web/src/lib/slashExec.ts — slash command pipeline (slash.exec with command.dispatch fallback + exec/plugin/alias/skill/send directive handling), mirrors ui-tui/src/app/createSlashHandler.ts. * web/src/pages/ChatPage.tsx — transcript + composer driven entirely by the WS. * web/src/components/SlashPopover.tsx — autocomplete popover above the composer, debounced complete.slash. * web/src/components/ModelPickerDialog.tsx — two-stage provider/model picker; confirms by emitting /model through the slash pipeline. * web/src/components/ToolCall.tsx — expandable tool call row (Ink-style chevron + context + summary/error/diff). * web/src/App.tsx — logo links to /, Chat entry added to nav. * web/src/pages/SessionsPage.tsx — every session row gets an Open-in-chat button that navigates to /chat?resume= (uses session.resume). * web/vite.config.ts — /api proxy configured with ws: true so WebSocket upgrades forward in dev mode; injectDevToken plugin reads HERMES_DASHBOARD_DEV_TOKEN and injects it into the served index.html so Vite HMR can authenticate against FastAPI without a separate flow. Tests ----- tests/hermes_cli/test_web_server.py picks up three new classes: * TestTuiGatewayWebSocket — handshake, auth rejection, parse errors, unknown methods, inline + pool handler round-trips, session event routing, disconnect cleanup. * TestTuiGatewayTransportParity — byte-identical envelopes for the same RPC over stdio vs WS (unknown method, inline handler, error envelope, explicit stdio transport). * TestTuiGatewayE2EAnyPort — scripted multi-RPC conversation driven identically via handle_request and via WebSocket; order + shape must match. This is the "hermes --tui in any port" check. Existing tests under tests/tui_gateway/ and tests/test_tui_gateway_server.py all still pass unchanged — backward compat preserved. Try it ------ hermes dashboard # builds web, serves on :9119, click Chat Dev with HMR: export HERMES_DASHBOARD_DEV_TOKEN="dev-\$(openssl rand -hex 16)" hermes dashboard --no-open cd web && npm run dev # :5173, /api + /api/ws proxied to :9119 fix(chat): insert tool rows before the streaming assistant message Transcript used to read "user → empty assistant bubble → tool → bubble filling in", which is disorienting: the streaming cursor sits at the top while the "work" rows appear below it chronologically. Now tool.start inserts the row just before the current streaming assistant message, so the order reads "user → tools → final message". If no streaming assistant exists yet (rare), tools still append at the end; tool.progress / tool.complete match by id regardless of position. fix(web-chat): font, composer, streaming caret + port GoodVibesHeart - ChatPage root opts out of App's `font-mondwest uppercase` (dashboard chrome style) — adds `font-courier normal-case` so transcript prose is readable mono mixed-case instead of pixel-display caps. - Composer: textarea + send button wrapped as one bordered unit with `focus-within` ring; `font-sans` dropped (it mapped to `Collapse` display). Heights stretch together via `items-stretch`; button is a flush cap with `border-l` divider. - Streaming caret no longer wraps to a new line when the assistant renders a block element. Markdown now takes a `streaming` prop and injects the caret inside the last block (paragraph, list item, code) so it hugs the trailing character. Caret sized in em units. - EmptyState gets a blinking caret + shortcut chips. - Port ui-tui's GoodVibesHeart easter egg to the web: typing "thanks" / "ty" / "ily" / "good bot" flashes a Lucide heart next to the connection badge (same regex, same 650ms beat, same palette as ui-tui/src/app/useMainApp.ts). --- hermes_cli/web_server.py | 38 +- tests/hermes_cli/test_web_server.py | 451 ++++++++++++++ tui_gateway/server.py | 81 ++- tui_gateway/transport.py | 91 +++ tui_gateway/ws.py | 174 ++++++ web/README.md | 16 +- web/src/App.tsx | 3 + web/src/components/Markdown.tsx | 128 +++- web/src/components/ModelPickerDialog.tsx | 392 ++++++++++++ web/src/components/SlashPopover.tsx | 174 ++++++ web/src/components/ToolCall.tsx | 228 +++++++ web/src/lib/gatewayClient.ts | 232 +++++++ web/src/lib/slashExec.ts | 163 +++++ web/src/pages/ChatPage.tsx | 752 +++++++++++++++++++++++ web/src/pages/SessionsPage.tsx | 32 + web/vite.config.ts | 6 +- 16 files changed, 2913 insertions(+), 48 deletions(-) create mode 100644 tui_gateway/transport.py create mode 100644 tui_gateway/ws.py create mode 100644 web/src/components/ModelPickerDialog.tsx create mode 100644 web/src/components/SlashPopover.tsx create mode 100644 web/src/components/ToolCall.tsx create mode 100644 web/src/lib/gatewayClient.ts create mode 100644 web/src/lib/slashExec.ts create mode 100644 web/src/pages/ChatPage.tsx diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 083e0714f..1a56c5df6 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -49,7 +49,7 @@ from hermes_cli.config import ( from gateway.status import get_running_pid, read_runtime_status try: - from fastapi import FastAPI, HTTPException, Request + from fastapi import FastAPI, HTTPException, Request, WebSocket from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, HTMLResponse, JSONResponse from fastapi.staticfiles import StaticFiles @@ -69,8 +69,14 @@ app = FastAPI(title="Hermes Agent", version=__version__) # Session token for protecting sensitive endpoints (reveal). # Generated fresh on every server start — dies when the process exits. # Injected into the SPA HTML so only the legitimate web UI can use it. +# +# Dev override: set HERMES_DASHBOARD_DEV_TOKEN to pin the token across +# restarts so the Vite dev server (running on a different port than the +# FastAPI backend) can inject the same value into its served index.html +# and hit /api/* + /api/ws successfully. Not for production. # --------------------------------------------------------------------------- -_SESSION_TOKEN = secrets.token_urlsafe(32) + +_SESSION_TOKEN = (os.environ.get("HERMES_DASHBOARD_DEV_TOKEN") or "").strip() or secrets.token_urlsafe(32) _SESSION_HEADER_NAME = "X-Hermes-Session-Token" # Simple rate limiter for the reveal endpoint @@ -2787,6 +2793,34 @@ def _mount_plugin_api_routes(): _log.warning("Failed to load plugin %s API routes: %s", plugin["name"], exc) +# --------------------------------------------------------------------------- +# tui_gateway WebSocket — wire-compatible with `python -m tui_gateway.entry`. +# +# Same newline-delimited JSON-RPC protocol the Ink TUI speaks over stdio, +# exposed over WebSocket so browser / iOS / Android clients can drive the +# exact same handlers with zero dispatcher duplication. +# +# Auth: client supplies the ephemeral session token via ``?token=`` query +# parameter, matching the REST auth model. Must be validated before ``accept`` +# so unauthorised clients never see any traffic. +# --------------------------------------------------------------------------- + + +@app.websocket("/api/ws") +async def _tui_gateway_websocket(ws: WebSocket): + """WebSocket entrypoint that replays stdio tui_gateway over a socket.""" + token = ws.query_params.get("token", "") + if not hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode()): + await ws.close(code=4401) + return + + # Imported lazily so this module can load in environments where + # tui_gateway isn't available (e.g. config-only tooling). + from tui_gateway.ws import handle_ws + + await handle_ws(ws) + + # Mount plugin API routes before the SPA catch-all. _mount_plugin_api_routes() diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index a92f0c8d1..1e8061d8d 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -1677,3 +1677,454 @@ class TestDashboardPluginManifestExtensions: plugins = web_server._get_dashboard_plugins(force_rescan=True) entry = next(p for p in plugins if p["name"] == "mixed-slots") assert entry["slots"] == ["sidebar", "header-right"] + + +# --------------------------------------------------------------------------- +# /api/ws — WebSocket wire-compatible with stdio tui_gateway +# --------------------------------------------------------------------------- + + +class TestTuiGatewayWebSocket: + """E2E tests for /api/ws. + + The WS endpoint multiplexes the same JSON-RPC protocol Ink speaks over + stdio onto a browser/iOS-friendly socket. These tests exercise the + transport boundary without booting a real AIAgent — handlers are + monkey-patched in for deterministic byte-level assertions. + """ + + @pytest.fixture(autouse=True) + def _setup(self): + try: + from starlette.testclient import TestClient + except ImportError: + pytest.skip("fastapi/starlette not installed") + from hermes_cli.web_server import app, _SESSION_TOKEN + self.client = TestClient(app) + self.token = _SESSION_TOKEN + + def _url(self, token=None): + tok = self.token if token is None else token + return f"/api/ws?token={tok}" if tok else "/api/ws" + + def _drain_ready(self, ws): + """Skip the ``gateway.ready`` event emitted on accept.""" + frame = ws.receive_json() + assert frame.get("method") == "event" + assert frame["params"]["type"] == "gateway.ready" + return frame + + def test_handshake_emits_gateway_ready(self): + with self.client.websocket_connect(self._url()) as ws: + first = ws.receive_json() + assert first["jsonrpc"] == "2.0" + assert first["method"] == "event" + assert first["params"]["type"] == "gateway.ready" + assert "skin" in first["params"]["payload"] + + def test_rejects_missing_token(self): + from starlette.websockets import WebSocketDisconnect + with pytest.raises(WebSocketDisconnect): + with self.client.websocket_connect(self._url(token="")) as ws: + ws.receive_json() + + def test_rejects_bad_token(self): + from starlette.websockets import WebSocketDisconnect + with pytest.raises(WebSocketDisconnect): + with self.client.websocket_connect(self._url(token="bogus-token-xyz")) as ws: + ws.receive_json() + + def test_parse_error_on_bad_frame(self): + with self.client.websocket_connect(self._url()) as ws: + self._drain_ready(ws) + ws.send_text("this is { not json") + resp = ws.receive_json() + assert resp["jsonrpc"] == "2.0" + assert resp["error"]["code"] == -32700 + assert resp["error"]["message"] == "parse error" + + def test_unknown_method_returns_rpc_error(self): + with self.client.websocket_connect(self._url()) as ws: + self._drain_ready(ws) + ws.send_json({"jsonrpc": "2.0", "id": "u1", "method": "does.not.exist"}) + resp = ws.receive_json() + assert resp["id"] == "u1" + assert resp["error"]["code"] == -32601 + assert "does.not.exist" in resp["error"]["message"] + + def test_inline_handler_returns_response(self): + """An inline handler's result round-trips via the WS transport.""" + from tui_gateway import server + + sentinel = "_ws_inline_test" + server._methods[sentinel] = lambda rid, params: server._ok(rid, {"pong": params.get("ping")}) + try: + with self.client.websocket_connect(self._url()) as ws: + self._drain_ready(ws) + ws.send_json({"jsonrpc": "2.0", "id": "i1", "method": sentinel, "params": {"ping": "PONG"}}) + resp = ws.receive_json() + assert resp == {"jsonrpc": "2.0", "id": "i1", "result": {"pong": "PONG"}} + finally: + server._methods.pop(sentinel, None) + + def test_pool_handler_response_arrives_via_ws(self): + """Long-handler responses written from the thread pool must reach the WS client.""" + from tui_gateway import server + + # Register a "slash.exec" replacement so we exercise the pool path + # (_LONG_HANDLERS includes "slash.exec"). + original = server._methods.get("slash.exec") + server._methods["slash.exec"] = lambda rid, params: server._ok(rid, {"output": "async-ok"}) + try: + with self.client.websocket_connect(self._url()) as ws: + self._drain_ready(ws) + ws.send_json({"jsonrpc": "2.0", "id": "p1", "method": "slash.exec", "params": {}}) + resp = ws.receive_json() + assert resp["id"] == "p1" + assert resp["result"] == {"output": "async-ok"} + finally: + if original is not None: + server._methods["slash.exec"] = original + else: + server._methods.pop("slash.exec", None) + + def test_session_events_route_to_owning_ws(self): + """Events emitted for a session created over WS land on that WS.""" + from tui_gateway import server + from tui_gateway.transport import current_transport + + sentinel_create = "_ws_emit_test_create" + sentinel_emit = "_ws_emit_test_fire" + created_sid = {"value": ""} + + def create(rid, params): + sid = f"ws-emit-test-{uuid_hex()}" + created_sid["value"] = sid + server._sessions[sid] = { + "session_key": sid, + "transport": current_transport(), + } + return server._ok(rid, {"session_id": sid}) + + def fire(rid, params): + sid = params["session_id"] + server._emit("demo.event", sid, {"n": params.get("n", 0)}) + return server._ok(rid, {"ok": True}) + + def uuid_hex(): + import uuid + return uuid.uuid4().hex[:8] + + server._methods[sentinel_create] = create + server._methods[sentinel_emit] = fire + try: + with self.client.websocket_connect(self._url()) as ws: + self._drain_ready(ws) + + ws.send_json({"jsonrpc": "2.0", "id": "c1", "method": sentinel_create}) + create_resp = ws.receive_json() + assert create_resp["id"] == "c1" + sid = create_resp["result"]["session_id"] + assert sid == created_sid["value"] + + ws.send_json({ + "jsonrpc": "2.0", + "id": "e1", + "method": sentinel_emit, + "params": {"session_id": sid, "n": 7}, + }) + # Event fires synchronously inside the handler, so it should + # arrive before the response. + frame1 = ws.receive_json() + frame2 = ws.receive_json() + + event_frame = frame1 if frame1.get("method") == "event" else frame2 + resp_frame = frame2 if frame2.get("id") == "e1" else frame1 + + assert event_frame["params"]["type"] == "demo.event" + assert event_frame["params"]["session_id"] == sid + assert event_frame["params"]["payload"] == {"n": 7} + assert resp_frame["result"] == {"ok": True} + finally: + server._methods.pop(sentinel_create, None) + server._methods.pop(sentinel_emit, None) + server._sessions.pop(created_sid["value"], None) + + def test_ws_disconnect_resets_session_transport(self): + """After a WS hangs up, sessions it owned fall back to stdio so stray emits don't crash.""" + from tui_gateway import server + from tui_gateway.transport import current_transport + + sentinel = "_ws_disconnect_test" + captured = {"sid": "", "transport": None} + + def create(rid, params): + sid = "ws-disconnect-sid" + captured["sid"] = sid + captured["transport"] = current_transport() + server._sessions[sid] = { + "session_key": sid, + "transport": captured["transport"], + } + return server._ok(rid, {"session_id": sid}) + + server._methods[sentinel] = create + try: + with self.client.websocket_connect(self._url()) as ws: + self._drain_ready(ws) + ws.send_json({"jsonrpc": "2.0", "id": "c1", "method": sentinel}) + ws.receive_json() + + # Give the server a moment to run the finally-block cleanup. + import time + for _ in range(50): + if server._sessions.get(captured["sid"], {}).get("transport") is not captured["transport"]: + break + time.sleep(0.02) + + sess = server._sessions.get(captured["sid"]) + assert sess is not None + assert sess["transport"] is server._stdio_transport + finally: + server._methods.pop(sentinel, None) + server._sessions.pop(captured["sid"], None) + + +# --------------------------------------------------------------------------- +# Transport parity — same RPC, stdio vs WS, byte-identical envelopes +# --------------------------------------------------------------------------- + + +class TestTuiGatewayTransportParity: + """The whole point of the transport abstraction is that handlers don't + know what's on the other end. These tests lock that in: the response + envelope produced by ``server.handle_request`` directly (stdio fast path) + must match what a WS client receives for the same request. + """ + + @pytest.fixture(autouse=True) + def _setup(self): + try: + from starlette.testclient import TestClient + except ImportError: + pytest.skip("fastapi/starlette not installed") + from hermes_cli.web_server import app, _SESSION_TOKEN + self.client = TestClient(app) + self.token = _SESSION_TOKEN + + def _ws_roundtrip(self, req: dict) -> dict: + with self.client.websocket_connect(f"/api/ws?token={self.token}") as ws: + ready = ws.receive_json() + assert ready["params"]["type"] == "gateway.ready" + ws.send_json(req) + return ws.receive_json() + + def test_parity_unknown_method(self): + from tui_gateway import server + req = {"jsonrpc": "2.0", "id": "p-unk", "method": "no.such.method"} + assert self._ws_roundtrip(req) == server.handle_request(req) + + def test_parity_inline_handler(self): + from tui_gateway import server + + sentinel = "_parity_inline" + server._methods[sentinel] = lambda rid, params: server._ok(rid, { + "echo": params, + "const": 42, + "nested": {"a": [1, 2, 3], "b": None}, + }) + try: + req = { + "jsonrpc": "2.0", + "id": "p-inline", + "method": sentinel, + "params": {"hello": "world", "n": 1}, + } + assert self._ws_roundtrip(req) == server.handle_request(req) + finally: + server._methods.pop(sentinel, None) + + def test_parity_error_envelope(self): + from tui_gateway import server + + sentinel = "_parity_err" + server._methods[sentinel] = lambda rid, params: server._err(rid, 4242, "nope") + try: + req = {"jsonrpc": "2.0", "id": "p-err", "method": sentinel} + assert self._ws_roundtrip(req) == server.handle_request(req) + finally: + server._methods.pop(sentinel, None) + + def test_parity_stdio_transport_also_works(self): + """Calling dispatch() with the stdio transport explicitly must match the default.""" + from tui_gateway import server + + sentinel = "_parity_stdio" + server._methods[sentinel] = lambda rid, params: server._ok(rid, {"ok": True, "p": params}) + try: + req = {"jsonrpc": "2.0", "id": "p-std", "method": sentinel, "params": {"x": 1}} + # Default (no transport arg) + default_resp = server.dispatch(dict(req)) + # Explicit stdio transport + explicit_resp = server.dispatch(dict(req), server._stdio_transport) + assert default_resp == explicit_resp + assert default_resp["result"] == {"ok": True, "p": {"x": 1}} + finally: + server._methods.pop(sentinel, None) + + +# --------------------------------------------------------------------------- +# E2E: drive the "Ink --tui" JSON-RPC surface over ANY transport +# --------------------------------------------------------------------------- + + +class TestTuiGatewayE2EAnyPort: + """Scripted multi-message conversations that exercise the real dispatcher. + + The same scripted sequence runs over (a) direct ``handle_request`` calls + and (b) a live WebSocket. Both must produce the same response envelopes + in the same order. This is the "hermes --tui in any port" check. + """ + + @pytest.fixture(autouse=True) + def _setup(self): + try: + from starlette.testclient import TestClient + except ImportError: + pytest.skip("fastapi/starlette not installed") + from hermes_cli.web_server import app, _SESSION_TOKEN + self.client = TestClient(app) + self.token = _SESSION_TOKEN + + def _install_scripted_methods(self): + """Install a tiny surface that mimics what Ink exercises on startup: + + - commands.ping returns a deterministic pong + - session.sim_create creates a fake session (no real agent) + - session.sim_close tears down the session + - config.sim_get_value reads a key + """ + from tui_gateway import server + from tui_gateway.transport import current_transport + + added = [] + + def ping(rid, params): + return server._ok(rid, {"pong": True, "id": rid}) + server._methods["commands.ping"] = ping + added.append("commands.ping") + + def sim_create(rid, params): + import uuid + sid = f"sim-{uuid.uuid4().hex[:6]}" + server._sessions[sid] = { + "session_key": sid, + "transport": current_transport(), + "agent": None, + } + return server._ok(rid, {"session_id": sid}) + server._methods["session.sim_create"] = sim_create + added.append("session.sim_create") + + def sim_close(rid, params): + sid = params.get("session_id", "") + removed = server._sessions.pop(sid, None) is not None + return server._ok(rid, {"closed": removed}) + server._methods["session.sim_close"] = sim_close + added.append("session.sim_close") + + def sim_get_value(rid, params): + return server._ok(rid, {"value": "deterministic", "key": params.get("key", "")}) + server._methods["config.sim_get_value"] = sim_get_value + added.append("config.sim_get_value") + + return added + + def _uninstall(self, added): + from tui_gateway import server + for name in added: + server._methods.pop(name, None) + + def _script(self): + return [ + {"jsonrpc": "2.0", "id": "s1", "method": "commands.ping"}, + {"jsonrpc": "2.0", "id": "s2", "method": "session.sim_create"}, + {"jsonrpc": "2.0", "id": "s3", "method": "config.sim_get_value", + "params": {"key": "display.skin"}}, + ] + + def test_script_over_direct_and_ws_match(self): + from tui_gateway import server + + added = self._install_scripted_methods() + try: + script = self._script() + + # Run over direct dispatch + direct_resps = [server.handle_request(dict(req)) for req in script] + # Clean up the session.create we just made so we don't leak into + # the WS run. + for r in direct_resps: + sid = (r.get("result") or {}).get("session_id") + if sid: + server._sessions.pop(sid, None) + + # Run over WS + with self.client.websocket_connect(f"/api/ws?token={self.token}") as ws: + ready = ws.receive_json() + assert ready["params"]["type"] == "gateway.ready" + + ws_resps = [] + for req in script: + ws.send_json(req) + ws_resps.append(ws.receive_json()) + + # Result shapes (stripping session-identity fields) should match. + def normalize(r): + r = dict(r) + if "result" in r and isinstance(r["result"], dict): + result = dict(r["result"]) + # session ids are random — compare only structure + if "session_id" in result: + result["session_id"] = "" + r["result"] = result + return r + + assert [normalize(r) for r in direct_resps] == [normalize(r) for r in ws_resps] + + # And both surfaces ACTUALLY executed their handlers. + assert all("result" in r for r in ws_resps) + assert ws_resps[0]["result"]["pong"] is True + assert ws_resps[2]["result"]["value"] == "deterministic" + finally: + # Clean up any sessions created during the WS run. + for sid in [ + sid for sid, sess in list(server._sessions.items()) if sid.startswith("sim-") + ]: + server._sessions.pop(sid, None) + self._uninstall(added) + + def test_session_lifecycle_over_ws(self): + """Open a session, then close it — via WS only.""" + from tui_gateway import server + + added = self._install_scripted_methods() + try: + with self.client.websocket_connect(f"/api/ws?token={self.token}") as ws: + ready = ws.receive_json() + assert ready["params"]["type"] == "gateway.ready" + + ws.send_json({"jsonrpc": "2.0", "id": "c1", "method": "session.sim_create"}) + create = ws.receive_json() + sid = create["result"]["session_id"] + assert sid in server._sessions + + ws.send_json({ + "jsonrpc": "2.0", "id": "x1", "method": "session.sim_close", + "params": {"session_id": sid}, + }) + close = ws.receive_json() + assert close["result"] == {"closed": True} + assert sid not in server._sessions + finally: + self._uninstall(added) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 52408ed9f..c9ed97385 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1,5 +1,6 @@ import atexit import concurrent.futures +import contextvars import copy import json import logging @@ -12,9 +13,17 @@ import time import uuid from datetime import datetime from pathlib import Path +from typing import Optional from hermes_constants import get_hermes_home 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__) @@ -147,6 +156,12 @@ atexit.register(lambda: _pool.shutdown(wait=False, cancel_futures=True)) _real_stdout = sys.stdout sys.stdout = sys.stderr +# Module-level stdio transport used as the fallback sink when no transport is +# bound via contextvar or session. The stream is resolved through a lambda so +# runtime monkey-patches of `_real_stdout` (used extensively in tests) still +# land in the right place. +_stdio_transport = StdioTransport(lambda: _real_stdout, _stdout_lock) + class _SlashWorker: """Persistent HermesCLI subprocess for slash commands.""" @@ -266,14 +281,24 @@ def _db_unavailable_error(rid, *, code: int): def write_json(obj: dict) -> bool: - line = json.dumps(obj, ensure_ascii=False) + "\n" - try: - with _stdout_lock: - _real_stdout.write(line) - _real_stdout.flush() - return True - except BrokenPipeError: - return False + """Emit one JSON frame. Routes via the most-specific transport available. + + Precedence: + + 1. Event frames with a session id → the transport stored on that session, + so async events land with the client that owns the session even if + the emitting thread has no contextvar binding. + 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): @@ -343,27 +368,39 @@ def handle_request(req: dict) -> dict | None: 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. Returns a response dict when handled inline. Returns None when the handler was scheduled on the pool; the worker writes its own - response via write_json when done. + response 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. + When omitted, writes fall back to the module-level stdio transport, + preserving the original behaviour for ``tui_gateway.entry``. """ - if req.get("method") not in _LONG_HANDLERS: - return handle_request(req) + t = transport or _stdio_transport + token = bind_transport(t) + try: + if req.get("method") not in _LONG_HANDLERS: + return handle_request(req) - def run(): - try: - resp = handle_request(req) - except Exception as exc: - resp = _err(req.get("id"), -32000, f"handler error: {exc}") - if resp is not None: - write_json(resp) + # Snapshot the context so the pool worker sees the bound transport. + ctx = contextvars.copy_context() - _pool.submit(run) + def run(): + try: + resp = handle_request(req) + except Exception as exc: + resp = _err(req.get("id"), -32000, f"handler error: {exc}") + if resp is not None: + t.write(resp) - return None + _pool.submit(lambda: ctx.run(run)) + return None + finally: + reset_transport(token) def _wait_agent(session: dict, rid: str, timeout: float = 30.0) -> dict | None: @@ -1256,6 +1293,7 @@ def _init_session(sid: str, key: str, agent, history: list, cols: int = 80): "tool_progress_mode": _load_tool_progress_mode(), "edit_snapshots": {}, "tool_started_at": {}, + "transport": current_transport() or _stdio_transport, } try: _sessions[sid]["slash_worker"] = _SlashWorker( @@ -1398,6 +1436,7 @@ def _(rid, params: dict) -> dict: "slash_worker": None, "tool_progress_mode": _load_tool_progress_mode(), "tool_started_at": {}, + "transport": current_transport() or _stdio_transport, } def _build() -> None: diff --git a/tui_gateway/transport.py b/tui_gateway/transport.py new file mode 100644 index 000000000..fcb0bdccb --- /dev/null +++ b/tui_gateway/transport.py @@ -0,0 +1,91 @@ +"""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 diff --git a/tui_gateway/ws.py b/tui_gateway/ws.py new file mode 100644 index 000000000..1661811db --- /dev/null +++ b/tui_gateway/ws.py @@ -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 diff --git a/web/README.md b/web/README.md index d8127f96e..be25145c0 100644 --- a/web/README.md +++ b/web/README.md @@ -11,16 +11,22 @@ Browser-based dashboard for managing Hermes Agent configuration, API keys, and m ## Development ```bash -# Start the backend API server -cd ../ -python -m hermes_cli.main web --no-open +# Pin a shared dev token so Vite (5173) and FastAPI (9119) agree. +# Without this, the SPA can't authenticate against the backend in dev mode. +export HERMES_DASHBOARD_DEV_TOKEN="dev-$(openssl rand -hex 16)" -# In another terminal, start the Vite dev server (with HMR + API proxy) +# Terminal 1 — backend on :9119 +hermes dashboard --no-open + +# Terminal 2 — Vite dev server on :5173 with HMR + /api proxy cd web/ npm run dev +# then open http://localhost:5173 ``` -The Vite dev server proxies `/api` requests to `http://127.0.0.1:9119` (the FastAPI backend). +The Vite dev server proxies `/api` and `/api/ws` (WebSocket) requests to `http://127.0.0.1:9119` (the FastAPI backend). The dev token is injected into the served `index.html` so the SPA's `window.__HERMES_SESSION_TOKEN__` matches what the backend expects. + +For a one-shot demo without HMR, skip the env var and just run `hermes dashboard` — it builds and serves the SPA directly on :9119 with a fresh random token injected. ## Build diff --git a/web/src/App.tsx b/web/src/App.tsx index 9c6e3c337..92455ea3d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -26,6 +26,7 @@ import { Cell, Grid, SelectionSwitcher, Typography } from "@nous-research/ui"; import { cn } from "@/lib/utils"; import { Backdrop } from "@/components/Backdrop"; import StatusPage from "@/pages/StatusPage"; +import ChatPage from "@/pages/ChatPage"; import ConfigPage from "@/pages/ConfigPage"; import EnvPage from "@/pages/EnvPage"; import SessionsPage from "@/pages/SessionsPage"; @@ -45,6 +46,7 @@ import { useTheme } from "@/themes"; * `path` in `BUILTIN_NAV` so `/path` lookups stay consistent. */ const BUILTIN_ROUTES: Record = { "/": StatusPage, + "/chat": ChatPage, "/sessions": SessionsPage, "/analytics": AnalyticsPage, "/logs": LogsPage, @@ -56,6 +58,7 @@ const BUILTIN_ROUTES: Record = { const BUILTIN_NAV: NavItem[] = [ { path: "/", labelKey: "status", label: "Status", icon: Activity }, + { path: "/chat", labelKey: "chat", label: "Chat", icon: Terminal }, { path: "/sessions", labelKey: "sessions", diff --git a/web/src/components/Markdown.tsx b/web/src/components/Markdown.tsx index b796ff0a7..bef0804e7 100644 --- a/web/src/components/Markdown.tsx +++ b/web/src/components/Markdown.tsx @@ -1,22 +1,50 @@ -import { useMemo } from "react"; +import { useMemo, type ReactNode } from "react"; /** * Lightweight markdown renderer for LLM output. * Handles: code blocks, inline code, bold, italic, headers, links, lists, horizontal rules. * 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 caret = streaming ? : null; return (
{blocks.map((block, i) => ( - + ))} + {blocks.length === 0 && caret}
); } +function StreamingCaret() { + return ( + + ); +} + /* ------------------------------------------------------------------ */ /* Types */ /* ------------------------------------------------------------------ */ @@ -58,7 +86,11 @@ function parseBlocks(text: string): BlockNode[] { // Heading const headingMatch = line.match(/^(#{1,4})\s+(.+)/); 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++; continue; } @@ -124,12 +156,23 @@ function parseBlocks(text: string): BlockNode[] { /* Block renderer */ /* ------------------------------------------------------------------ */ -function Block({ block, highlightTerms }: { block: BlockNode; highlightTerms?: string[] }) { +function Block({ + block, + highlightTerms, + caret, +}: { + block: BlockNode; + highlightTerms?: string[]; + caret?: ReactNode; +}) { switch (block.type) { case "code": return (
-          {block.content}
+          
+            {block.content}
+            {caret}
+          
         
); @@ -141,25 +184,46 @@ function Block({ block, highlightTerms }: { block: BlockNode; highlightTerms?: s h3: "text-sm font-semibold", h4: "text-sm font-medium", }; - return ; + return ( + + + {caret} + + ); } case "hr": - return
; + return ( + <> +
+ {caret} + + ); case "list": { const Tag = block.ordered ? "ol" : "ul"; + const last = block.items.length - 1; return ( - + {block.items.map((item, i) => ( -
  • +
  • + + {i === last ? caret : null} +
  • ))}
    ); } case "paragraph": - return

    ; + return ( +

    + + {caret} +

    + ); } } @@ -178,7 +242,8 @@ type InlineNode = function parseInline(text: string): InlineNode[] { const nodes: InlineNode[] = []; // 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 match: RegExpExecArray | null; @@ -217,7 +282,13 @@ function parseInline(text: string): InlineNode[] { return nodes; } -function InlineContent({ text, highlightTerms }: { text: string; highlightTerms?: string[] }) { +function InlineContent({ + text, + highlightTerms, +}: { + text: string; + highlightTerms?: string[]; +}) { const nodes = useMemo(() => parseInline(text), [text]); return ( @@ -225,17 +296,34 @@ function InlineContent({ text, highlightTerms }: { text: string; highlightTerms? {nodes.map((node, i) => { switch (node.type) { case "text": - return ; + return ( + + ); case "code": return ( - + {node.content} ); case "bold": - return ; + return ( + + + + ); case "italic": - return ; + return ( + + + + ); case "link": return ( {parts.map((part, i) => regex.test(part) ? ( - {part} + + {part} + ) : ( {part} - ) + ), )} ); diff --git a/web/src/components/ModelPickerDialog.tsx b/web/src/components/ModelPickerDialog.tsx new file mode 100644 index 000000000..13a7268ac --- /dev/null +++ b/web/src/components/ModelPickerDialog.tsx @@ -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 --provider [--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([]); + const [currentModel, setCurrentModel] = useState(""); + const [currentProviderSlug, setCurrentProviderSlug] = useState(""); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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( + "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 ( +
    e.target === e.currentTarget && onClose()} + role="dialog" + aria-modal="true" + aria-labelledby="model-picker-title" + > +
    + + +
    +

    + Switch Model +

    +

    + current: {currentModel || "(unknown)"} + {currentProviderSlug && ` · ${currentProviderSlug}`} +

    +
    + +
    +
    + + setQuery(e.target.value)} + className="pl-7 h-8 text-sm" + /> +
    +
    + +
    + { + setSelectedSlug(slug); + setSelectedModel(""); + }} + /> + + { + setSelectedModel(m); + // Confirm on next tick so state settles. + window.setTimeout(confirm, 0); + }} + /> +
    + +
    + + +
    + + +
    +
    +
    +
    + ); +} + +/* ------------------------------------------------------------------ */ +/* 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 ( +
    + {loading && ( +
    + loading… +
    + )} + + {error &&
    {error}
    } + + {!loading && !error && providers.length === 0 && ( +
    + {query + ? "no matches" + : total === 0 + ? "no authenticated providers" + : "no matches"} +
    + )} + + {providers.map((p) => { + const active = p.slug === selectedSlug; + return ( + + ); + })} +
    + ); +} + +/* ------------------------------------------------------------------ */ +/* 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 ( +
    +
    + pick a provider → +
    +
    + ); + } + + return ( +
    + {provider.warning && ( +
    + {provider.warning} +
    + )} + + {models.length === 0 ? ( +
    + {allModels.length + ? "no models match your filter" + : "no models listed for this provider"} +
    + ) : ( + models.map((m) => { + const active = m === selectedModel; + const isCurrent = + m === currentModel && provider.slug === currentProviderSlug; + + return ( + + ); + }) + )} +
    + ); +} + +function CurrentTag() { + return ( + + current + + ); +} diff --git a/web/src/components/SlashPopover.tsx b/web/src/components/SlashPopover.tsx new file mode 100644 index 000000000..1c4b273b3 --- /dev/null +++ b/web/src/components/SlashPopover.tsx @@ -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): 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( + function SlashPopover({ input, gw, onApply }, ref) { + const [items, setItems] = useState([]); + const [selected, setSelected] = useState(0); + const [replaceFrom, setReplaceFrom] = useState(1); + const lastInputRef = useRef(""); + + // 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("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 ( +
    + {items.map((it, i) => { + const active = i === selected; + + return ( + + ); + })} +
    + ); + }, +); diff --git a/web/src/components/ToolCall.tsx b/web/src/components/ToolCall.tsx new file mode 100644 index 000000000..8ac1ebce6 --- /dev/null +++ b/web/src/components/ToolCall.tsx @@ -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 = { + 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 = { + 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(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 ( +
    + + + {open && hasBody && ( +
    + {tool.context &&
    {tool.context}
    } + + {tool.preview && tool.status === "running" && ( +
    + {tool.preview} + +
    + )} + + {tool.inline_diff && ( +
    +
    +                {colorizeDiff(tool.inline_diff)}
    +              
    +
    + )} + + {tool.summary && ( +
    + + {tool.summary} + +
    + )} + + {tool.error && ( +
    + + {tool.error} + +
    + )} +
    + )} +
    + ); +} + +function Section({ + label, + children, + tone, +}: { + label: string; + children: React.ReactNode; + tone?: "error"; +}) { + return ( +
    + + {label} + + +
    {children}
    +
    + ); +} + +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) => ( +
    + {line || "\u00A0"} +
    + )); +} + +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"; +} diff --git a/web/src/lib/gatewayClient.ts b/web/src/lib/gatewayClient.ts new file mode 100644 index 000000000..9de2205b2 --- /dev/null +++ b/web/src/lib/gatewayClient.ts @@ -0,0 +1,232 @@ +/** + * 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

    { + 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; +} + +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(); + private listeners = new Map 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

    ( + type: GatewayEventName, + cb: (ev: GatewayEvent

    ) => 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 { + 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; + + await new Promise((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 }); + }); + + 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")); + }); + } + + close() { + this.ws?.close(); + this.ws = null; + } + + private dispatch(msg: Record) { + 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( + method: string, + params: Record = {}, + timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, + ): Promise { + 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((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; + } +} diff --git a/web/src/lib/slashExec.ts b/web/src/lib/slashExec.ts new file mode 100644 index 000000000..c232f2aa4 --- /dev/null +++ b/web/src/lib/slashExec.ts @@ -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; +} + +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 { + const { name, arg } = parseSlash(command); + + if (!name) { + sys("empty slash command"); + return "error"; + } + + // Primary dispatcher. + try { + const r = await gw.request("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("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; + 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; + } +} diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx new file mode 100644 index 000000000..79a069656 --- /dev/null +++ b/web/src/pages/ChatPage.tsx @@ -0,0 +1,752 @@ +import { Markdown } from "@/components/Markdown"; +import { ModelPickerDialog } from "@/components/ModelPickerDialog"; +import { + SlashPopover, + type SlashPopoverHandle, +} from "@/components/SlashPopover"; +import { ToolCall, type ToolEntry } from "@/components/ToolCall"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { GatewayClient, type ConnectionState } from "@/lib/gatewayClient"; +import { executeSlash } from "@/lib/slashExec"; +import { + AlertCircle, + ChevronDown, + Copy, + Heart, + RefreshCw, + Send, + Square, +} from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useSearchParams } from "react-router-dom"; + +/* + * Chat — the "Ink TUI in a browser" proof. + * + * Drives the exact same tui_gateway JSON-RPC surface Ink drives over stdio, + * but over a WebSocket served by hermes_cli/web_server.py. Covers message + * streaming, tool calls, interrupts, slash commands, and model switching. + * Approvals / clarify / resume picker / attachments are still TODO; the + * event listeners on GatewayClient give type-safe hooks for each. + */ + +type MessageRole = "user" | "assistant" | "system"; + +interface TextMessage { + kind: "message"; + id: string; + role: MessageRole; + text: string; + streaming?: boolean; + rendered?: string; + error?: boolean; +} + +type ChatEntry = TextMessage | ToolEntry; + +/** Shape of messages returned by session.resume — see _history_to_messages in tui_gateway/server.py. */ +interface HydratedMessage { + role: "user" | "assistant" | "system" | "tool"; + text?: string; + name?: string; + context?: string; +} + +interface SessionResumeResponse { + session_id: string; + resumed: string; + message_count: number; + messages: HydratedMessage[]; + info?: Record; +} + +interface SessionInfo { + model?: string; + provider?: string; + cwd?: string; + tools?: Record; + skills?: Record; + credential_warning?: string; +} + +const STATE_LABEL: Record = { + idle: "idle", + connecting: "connecting", + open: "connected", + closed: "closed", + error: "error", +}; + +const STATE_TONE: Record = { + 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", +}; + +const randId = (prefix: string) => + `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + +// Mirror ui-tui/src/app/useMainApp.ts — same regex, same palette, same beat. +// Web parity with the Ink TUI's GoodVibesHeart easter egg: a thank-you pulses +// a heart next to the connection badge. +const GOOD_VIBES_RE = /\b(good bot|thanks|thank you|thx|ty|ily|love you)\b/i; +const HEART_COLORS = ["#ff5fa2", "#ff4d6d", "#ffbd38"]; + +export default function ChatPage() { + const gwRef = useRef(null); + const slashRef = useRef(null); + const transcriptEndRef = useRef(null); + const textareaRef = useRef(null); + + const [searchParams] = useSearchParams(); + const resumeId = searchParams.get("resume") ?? ""; + + const [connState, setConnState] = useState("idle"); + const [sessionId, setSessionId] = useState(""); + const [sessionInfo, setSessionInfo] = useState(null); + const [entries, setEntries] = useState([]); + const [draft, setDraft] = useState(""); + const [busy, setBusy] = useState(false); + const [connectError, setConnectError] = useState(""); + const [runtimeError, setRuntimeError] = useState(""); + const [modelPickerOpen, setModelPickerOpen] = useState(false); + const [goodVibesTick, setGoodVibesTick] = useState(0); + + /* ---------------------------------------------------------------- */ + /* Entry helpers */ + /* ---------------------------------------------------------------- */ + + /** Replace the most recent streaming assistant message, if any. */ + const updateStreamingAssistant = useCallback( + (fn: (m: TextMessage) => TextMessage) => { + setEntries((list) => { + for (let i = list.length - 1; i >= 0; i--) { + const e = list[i]; + if (e.kind === "message" && e.role === "assistant" && e.streaming) { + const next = list.slice(); + next[i] = fn(e); + return next; + } + } + return list; + }); + }, + [], + ); + + const pushMessage = useCallback( + (role: MessageRole, text: string, extra: Partial = {}) => { + setEntries((list) => [ + ...list, + { kind: "message", id: randId(role[0]), role, text, ...extra }, + ]); + }, + [], + ); + + const pushSystem = useCallback( + (text: string) => pushMessage("system", text), + [pushMessage], + ); + + /* ---------------------------------------------------------------- */ + /* Bootstrap: connect, wire events, open or resume a session */ + /* ---------------------------------------------------------------- */ + + const bootstrap = useCallback(async () => { + setEntries([]); + setSessionId(""); + setSessionInfo(null); + setBusy(false); + setConnectError(""); + setRuntimeError(""); + + const gw = gwRef.current ?? new GatewayClient(); + gwRef.current = gw; + + gw.onState(setConnState); + + gw.on("session.info", (ev) => { + if (ev.payload) setSessionInfo(ev.payload); + }); + + gw.on("message.start", () => { + pushMessage("assistant", "", { streaming: true }); + setBusy(true); + }); + + gw.on<{ text?: string; rendered?: string }>("message.delta", (ev) => { + const d = ev.payload?.text ?? ""; + if (!d) return; + updateStreamingAssistant((m) => ({ ...m, text: m.text + d })); + }); + + gw.on<{ text?: string; rendered?: string; reasoning?: string }>( + "message.complete", + (ev) => { + updateStreamingAssistant((m) => ({ + ...m, + text: ev.payload?.text ?? m.text, + rendered: ev.payload?.rendered, + streaming: false, + })); + setBusy(false); + }, + ); + + gw.on<{ tool_id: string; name?: string; context?: string }>( + "tool.start", + (ev) => { + if (!ev.payload) return; + const { tool_id, name, context } = ev.payload; + + // Insert tool rows BEFORE the current streaming assistant bubble so + // the transcript reads "user → tools → final message" rather than + // "empty bubble → tool → bubble filling in". If there's no streaming + // assistant (tool fired before message.start, or no message at all), + // append to the end. + const row: ToolEntry = { + kind: "tool", + id: `t-${tool_id}`, + tool_id, + name: name ?? "tool", + context, + status: "running", + startedAt: Date.now(), + }; + + setEntries((list) => { + for (let i = list.length - 1; i >= 0; i--) { + const e = list[i]; + if (e.kind === "message" && e.role === "assistant" && e.streaming) { + return [...list.slice(0, i), row, ...list.slice(i)]; + } + } + return [...list, row]; + }); + }, + ); + + gw.on<{ name?: string; preview?: string }>("tool.progress", (ev) => { + const name = ev.payload?.name ?? ""; + const preview = ev.payload?.preview ?? ""; + if (!name || !preview) return; + + // Update the most recent running tool entry with this name. + setEntries((list) => { + for (let i = list.length - 1; i >= 0; i--) { + const e = list[i]; + if (e.kind === "tool" && e.status === "running" && e.name === name) { + const next = list.slice(); + next[i] = { ...e, preview }; + return next; + } + } + return list; + }); + }); + + gw.on<{ + tool_id: string; + name?: string; + summary?: string; + error?: string; + inline_diff?: string; + }>("tool.complete", (ev) => { + if (!ev.payload) return; + const { tool_id, summary, error, inline_diff } = ev.payload; + + setEntries((list) => + list.map((e) => + e.kind === "tool" && e.tool_id === tool_id + ? { + ...e, + status: error ? "error" : "done", + summary: summary ?? (error ? undefined : e.summary), + error: error ?? e.error, + inline_diff: inline_diff ?? e.inline_diff, + completedAt: Date.now(), + } + : e, + ), + ); + }); + + gw.on<{ message?: string }>("error", (ev) => { + setRuntimeError(ev.payload?.message ?? "unknown error"); + setBusy(false); + }); + + try { + await gw.connect(); + + if (resumeId) { + const resp = await gw.request("session.resume", { + session_id: resumeId, + cols: 100, + }); + setSessionId(resp.session_id); + setEntries(hydrateMessages(resp.messages ?? [])); + pushSystem( + `resumed session ${resp.resumed} · ${resp.message_count ?? resp.messages?.length ?? 0} messages`, + ); + // NOTE: intentionally NOT clearing the ?resume= param. Doing so + // flips `resumeId` back to "" which is a dep of the bootstrap + // effect, re-triggering cleanup + a fresh session.create and + // wiping the transcript we just hydrated. + } else { + const { session_id } = await gw.request<{ session_id: string }>( + "session.create", + { cols: 100 }, + ); + setSessionId(session_id); + } + } catch (err) { + setConnectError(err instanceof Error ? err.message : String(err)); + } + }, [pushMessage, pushSystem, resumeId, updateStreamingAssistant]); + + // Rebootstrap whenever the resume target changes. React Router keeps the + // component mounted when the search params flip, so navigating to + // /chat?resume=X from within the app must tear down the current WS + // connection and open a fresh session. + useEffect(() => { + bootstrap(); + return () => { + gwRef.current?.close(); + gwRef.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [resumeId]); + + useEffect(() => { + transcriptEndRef.current?.scrollIntoView({ + behavior: "smooth", + block: "end", + }); + }, [entries]); + + /* ---------------------------------------------------------------- */ + /* Submission */ + /* ---------------------------------------------------------------- */ + + const submitUserMessage = useCallback( + async (text: string) => { + const gw = gwRef.current; + const trimmed = text.trim(); + if (!gw || !sessionId || !trimmed) return; + + pushMessage("user", trimmed); + setRuntimeError(""); + + try { + await gw.request("prompt.submit", { + session_id: sessionId, + text: trimmed, + }); + } catch (err) { + setRuntimeError(err instanceof Error ? err.message : String(err)); + setBusy(false); + updateStreamingAssistant((m) => ({ + ...m, + streaming: false, + error: true, + })); + } + }, + [sessionId, pushMessage, updateStreamingAssistant], + ); + + const submitSlash = useCallback( + async (command: string) => { + const gw = gwRef.current; + if (!gw || !sessionId) return; + + pushSystem(command); + await executeSlash({ + command, + sessionId, + gw, + callbacks: { sys: pushSystem, send: submitUserMessage }, + }); + }, + [sessionId, pushSystem, submitUserMessage], + ); + + const send = useCallback(async () => { + const text = draft.trim(); + if (!text || busy || !sessionId) return; + + setDraft(""); + if (!text.startsWith("/") && GOOD_VIBES_RE.test(text)) { + setGoodVibesTick((v) => v + 1); + } + await (text.startsWith("/") ? submitSlash(text) : submitUserMessage(text)); + }, [busy, draft, sessionId, submitSlash, submitUserMessage]); + + const interrupt = useCallback(() => { + gwRef.current + ?.request("session.interrupt", { session_id: sessionId }) + .catch(() => { + /* resync on next status event */ + }); + }, [sessionId]); + + /* ---------------------------------------------------------------- */ + /* Render */ + /* ---------------------------------------------------------------- */ + + const canSend = + connState === "open" && !!sessionId && !busy && draft.trim().length > 0; + const canPickModel = connState === "open" && !!sessionId; + const placeholder = + connState !== "open" + ? "waiting for gateway…" + : busy + ? "agent is running — press Interrupt to stop, or queue a follow-up" + : "message hermes… (Enter to send, Shift+Enter for newline, / for commands)"; + + return ( + // Opt out of the App root's `font-mondwest uppercase` — the dashboard + // uses pixel-display caps for chrome, but chat prose needs readable + // mixed-case. `font-courier` matches the terminal aesthetic without + // fighting the rest of the app's typography. +

    +
    +
    + + + {STATE_LABEL[connState]} + + + + + setModelPickerOpen(true)} + /> + + {sessionId && ( + + )} +
    + +
    + {busy && ( + + )} + + +
    +
    + + {connectError && ( + + +
    +
    + Can't connect to gateway +
    +
    + {connectError} +
    +
    +
    + )} + + +
    + {entries.length === 0 && !connectError && ( + + )} + + {entries.map((entry) => + entry.kind === "tool" ? ( + + ) : ( + + ), + )} + + {runtimeError && ( +
    + + {runtimeError} +
    + )} + +
    +
    + +
    + { + setDraft(next); + textareaRef.current?.focus(); + }} + /> + +
    +