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(); + }} + /> + +
    +