mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat(tui-gateway): WebSocket transport + /chat web UI, wire-compatible with Ink
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=<id> (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 + <kbd> 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).
This commit is contained in:
parent
c95c6bdb7c
commit
25ba6783b8
16 changed files with 2913 additions and 48 deletions
|
|
@ -49,7 +49,7 @@ from hermes_cli.config import (
|
||||||
from gateway.status import get_running_pid, read_runtime_status
|
from gateway.status import get_running_pid, read_runtime_status
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from fastapi import FastAPI, HTTPException, Request
|
from fastapi import FastAPI, HTTPException, Request, WebSocket
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
@ -69,8 +69,14 @@ app = FastAPI(title="Hermes Agent", version=__version__)
|
||||||
# Session token for protecting sensitive endpoints (reveal).
|
# Session token for protecting sensitive endpoints (reveal).
|
||||||
# Generated fresh on every server start — dies when the process exits.
|
# 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.
|
# 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"
|
_SESSION_HEADER_NAME = "X-Hermes-Session-Token"
|
||||||
|
|
||||||
# Simple rate limiter for the reveal endpoint
|
# 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)
|
_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 before the SPA catch-all.
|
||||||
_mount_plugin_api_routes()
|
_mount_plugin_api_routes()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1677,3 +1677,454 @@ class TestDashboardPluginManifestExtensions:
|
||||||
plugins = web_server._get_dashboard_plugins(force_rescan=True)
|
plugins = web_server._get_dashboard_plugins(force_rescan=True)
|
||||||
entry = next(p for p in plugins if p["name"] == "mixed-slots")
|
entry = next(p for p in plugins if p["name"] == "mixed-slots")
|
||||||
assert entry["slots"] == ["sidebar", "header-right"]
|
assert entry["slots"] == ["sidebar", "header-right"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /api/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"] = "<random>"
|
||||||
|
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)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import atexit
|
import atexit
|
||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
|
import contextvars
|
||||||
import copy
|
import copy
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
@ -12,9 +13,17 @@ import time
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from hermes_constants import get_hermes_home
|
from hermes_constants import get_hermes_home
|
||||||
from hermes_cli.env_loader import load_hermes_dotenv
|
from hermes_cli.env_loader import load_hermes_dotenv
|
||||||
|
from tui_gateway.transport import (
|
||||||
|
StdioTransport,
|
||||||
|
Transport,
|
||||||
|
bind_transport,
|
||||||
|
current_transport,
|
||||||
|
reset_transport,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -147,6 +156,12 @@ atexit.register(lambda: _pool.shutdown(wait=False, cancel_futures=True))
|
||||||
_real_stdout = sys.stdout
|
_real_stdout = sys.stdout
|
||||||
sys.stdout = sys.stderr
|
sys.stdout = sys.stderr
|
||||||
|
|
||||||
|
# Module-level stdio transport 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:
|
class _SlashWorker:
|
||||||
"""Persistent HermesCLI subprocess for slash commands."""
|
"""Persistent HermesCLI subprocess for slash commands."""
|
||||||
|
|
@ -266,14 +281,24 @@ def _db_unavailable_error(rid, *, code: int):
|
||||||
|
|
||||||
|
|
||||||
def write_json(obj: dict) -> bool:
|
def write_json(obj: dict) -> bool:
|
||||||
line = json.dumps(obj, ensure_ascii=False) + "\n"
|
"""Emit one JSON frame. Routes via the most-specific transport available.
|
||||||
try:
|
|
||||||
with _stdout_lock:
|
Precedence:
|
||||||
_real_stdout.write(line)
|
|
||||||
_real_stdout.flush()
|
1. Event frames with a session id → the transport stored on that session,
|
||||||
return True
|
so async events land with the client that owns the session even if
|
||||||
except BrokenPipeError:
|
the emitting thread has no contextvar binding.
|
||||||
return False
|
2. Otherwise the transport bound on the current context (set by
|
||||||
|
:func:`dispatch` for the lifetime of a request).
|
||||||
|
3. Otherwise the module-level stdio transport, matching the historical
|
||||||
|
behaviour and keeping tests that monkey-patch ``_real_stdout`` green.
|
||||||
|
"""
|
||||||
|
if obj.get("method") == "event":
|
||||||
|
sid = ((obj.get("params") or {}).get("session_id")) or ""
|
||||||
|
if sid and (t := (_sessions.get(sid) or {}).get("transport")) is not None:
|
||||||
|
return t.write(obj)
|
||||||
|
|
||||||
|
return (current_transport() or _stdio_transport).write(obj)
|
||||||
|
|
||||||
|
|
||||||
def _emit(event: str, sid: str, payload: dict | None = None):
|
def _emit(event: str, sid: str, payload: dict | None = None):
|
||||||
|
|
@ -343,27 +368,39 @@ def handle_request(req: dict) -> dict | None:
|
||||||
return fn(req.get("id"), req.get("params", {}))
|
return fn(req.get("id"), req.get("params", {}))
|
||||||
|
|
||||||
|
|
||||||
def dispatch(req: dict) -> dict | None:
|
def dispatch(req: dict, transport: Optional[Transport] = None) -> dict | None:
|
||||||
"""Route inbound RPCs — long handlers to the pool, everything else inline.
|
"""Route inbound RPCs — long handlers to the pool, everything else inline.
|
||||||
|
|
||||||
Returns a response dict when handled inline. Returns None when the
|
Returns a response dict when handled inline. Returns None when the
|
||||||
handler was scheduled on the pool; the worker writes its own
|
handler was scheduled on the pool; the worker writes its own
|
||||||
response 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:
|
t = transport or _stdio_transport
|
||||||
return handle_request(req)
|
token = bind_transport(t)
|
||||||
|
try:
|
||||||
|
if req.get("method") not in _LONG_HANDLERS:
|
||||||
|
return handle_request(req)
|
||||||
|
|
||||||
def run():
|
# Snapshot the context so the pool worker sees the bound transport.
|
||||||
try:
|
ctx = contextvars.copy_context()
|
||||||
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)
|
|
||||||
|
|
||||||
_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:
|
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(),
|
"tool_progress_mode": _load_tool_progress_mode(),
|
||||||
"edit_snapshots": {},
|
"edit_snapshots": {},
|
||||||
"tool_started_at": {},
|
"tool_started_at": {},
|
||||||
|
"transport": current_transport() or _stdio_transport,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
_sessions[sid]["slash_worker"] = _SlashWorker(
|
_sessions[sid]["slash_worker"] = _SlashWorker(
|
||||||
|
|
@ -1398,6 +1436,7 @@ def _(rid, params: dict) -> dict:
|
||||||
"slash_worker": None,
|
"slash_worker": None,
|
||||||
"tool_progress_mode": _load_tool_progress_mode(),
|
"tool_progress_mode": _load_tool_progress_mode(),
|
||||||
"tool_started_at": {},
|
"tool_started_at": {},
|
||||||
|
"transport": current_transport() or _stdio_transport,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _build() -> None:
|
def _build() -> None:
|
||||||
|
|
|
||||||
91
tui_gateway/transport.py
Normal file
91
tui_gateway/transport.py
Normal file
|
|
@ -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
|
||||||
174
tui_gateway/ws.py
Normal file
174
tui_gateway/ws.py
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
"""WebSocket transport for the tui_gateway JSON-RPC server.
|
||||||
|
|
||||||
|
Reuses :func:`tui_gateway.server.dispatch` verbatim so every RPC method, every
|
||||||
|
slash command, every approval/clarify/sudo flow, and every agent event flows
|
||||||
|
through the same handlers whether the client is Ink over stdio or an iOS /
|
||||||
|
web client over WebSocket.
|
||||||
|
|
||||||
|
Wire protocol
|
||||||
|
-------------
|
||||||
|
Identical to stdio: newline-delimited JSON-RPC in both directions. The server
|
||||||
|
emits a ``gateway.ready`` event immediately after connection accept, then
|
||||||
|
echoes responses/events for inbound requests. No framing differences.
|
||||||
|
|
||||||
|
Mounting
|
||||||
|
--------
|
||||||
|
from fastapi import WebSocket
|
||||||
|
from tui_gateway.ws import handle_ws
|
||||||
|
|
||||||
|
@app.websocket("/api/ws")
|
||||||
|
async def ws(ws: WebSocket):
|
||||||
|
await handle_ws(ws)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from tui_gateway import server
|
||||||
|
|
||||||
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Max seconds a pool-dispatched handler will block waiting for the event loop
|
||||||
|
# to flush a WS frame before we mark the transport dead. Protects handler
|
||||||
|
# threads from a wedged socket.
|
||||||
|
_WS_WRITE_TIMEOUT_S = 10.0
|
||||||
|
|
||||||
|
# Keep starlette optional at import time; handle_ws uses the real class when
|
||||||
|
# it's available and falls back to a generic Exception sentinel otherwise.
|
||||||
|
try:
|
||||||
|
from starlette.websockets import WebSocketDisconnect as _WebSocketDisconnect
|
||||||
|
except ImportError: # pragma: no cover - starlette is a required install path
|
||||||
|
_WebSocketDisconnect = Exception # type: ignore[assignment]
|
||||||
|
|
||||||
|
|
||||||
|
class WSTransport:
|
||||||
|
"""Per-connection WS transport.
|
||||||
|
|
||||||
|
``write`` is safe to call from any thread *other than* the event loop
|
||||||
|
thread that owns the socket. Pool workers (the only real caller) run in
|
||||||
|
their own threads, so marshalling onto the loop via
|
||||||
|
:func:`asyncio.run_coroutine_threadsafe` + ``future.result()`` is correct
|
||||||
|
and deadlock-free there.
|
||||||
|
|
||||||
|
When called from the loop thread itself (e.g. by ``handle_ws`` for an
|
||||||
|
inline response) the same call would deadlock: we'd schedule work onto
|
||||||
|
the loop we're currently blocking. We detect that case and fire-and-
|
||||||
|
forget instead. Callers that need to know when the bytes are on the wire
|
||||||
|
should use :meth:`write_async` from the loop thread.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, ws: Any, loop: asyncio.AbstractEventLoop) -> None:
|
||||||
|
self._ws = ws
|
||||||
|
self._loop = loop
|
||||||
|
self._closed = False
|
||||||
|
|
||||||
|
def write(self, obj: dict) -> bool:
|
||||||
|
if self._closed:
|
||||||
|
return False
|
||||||
|
|
||||||
|
line = json.dumps(obj, ensure_ascii=False)
|
||||||
|
|
||||||
|
try:
|
||||||
|
on_loop = asyncio.get_running_loop() is self._loop
|
||||||
|
except RuntimeError:
|
||||||
|
on_loop = False
|
||||||
|
|
||||||
|
if on_loop:
|
||||||
|
# Fire-and-forget — don't block the loop waiting on itself.
|
||||||
|
self._loop.create_task(self._safe_send(line))
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
fut = asyncio.run_coroutine_threadsafe(self._safe_send(line), self._loop)
|
||||||
|
fut.result(timeout=_WS_WRITE_TIMEOUT_S)
|
||||||
|
return not self._closed
|
||||||
|
except Exception as exc:
|
||||||
|
self._closed = True
|
||||||
|
_log.debug("ws write failed: %s", exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def write_async(self, obj: dict) -> bool:
|
||||||
|
"""Send from the owning event loop. Awaits until the frame is on the wire."""
|
||||||
|
if self._closed:
|
||||||
|
return False
|
||||||
|
await self._safe_send(json.dumps(obj, ensure_ascii=False))
|
||||||
|
return not self._closed
|
||||||
|
|
||||||
|
async def _safe_send(self, line: str) -> None:
|
||||||
|
try:
|
||||||
|
await self._ws.send_text(line)
|
||||||
|
except Exception as exc:
|
||||||
|
self._closed = True
|
||||||
|
_log.debug("ws send failed: %s", exc)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._closed = True
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_ws(ws: Any) -> None:
|
||||||
|
"""Run one WebSocket session. Wire-compatible with ``tui_gateway.entry``."""
|
||||||
|
await ws.accept()
|
||||||
|
|
||||||
|
transport = WSTransport(ws, asyncio.get_running_loop())
|
||||||
|
|
||||||
|
await transport.write_async(
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "event",
|
||||||
|
"params": {
|
||||||
|
"type": "gateway.ready",
|
||||||
|
"payload": {"skin": server.resolve_skin()},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
raw = await ws.receive_text()
|
||||||
|
except _WebSocketDisconnect:
|
||||||
|
break
|
||||||
|
|
||||||
|
line = raw.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
req = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
ok = await transport.write_async(
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"error": {"code": -32700, "message": "parse error"},
|
||||||
|
"id": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if not ok:
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
|
||||||
|
# dispatch() may schedule long handlers on the pool; it returns
|
||||||
|
# None in that case and the worker writes the response itself via
|
||||||
|
# the transport we pass in (a separate thread, so transport.write
|
||||||
|
# is the safe path there). For inline handlers it returns the
|
||||||
|
# response dict, which we write here from the loop.
|
||||||
|
resp = await asyncio.to_thread(server.dispatch, req, transport)
|
||||||
|
if resp is not None and not await transport.write_async(resp):
|
||||||
|
break
|
||||||
|
finally:
|
||||||
|
transport.close()
|
||||||
|
|
||||||
|
# Detach the transport from any sessions it owned so later emits
|
||||||
|
# fall back to stdio instead of crashing into a closed socket.
|
||||||
|
for _, sess in list(server._sessions.items()):
|
||||||
|
if sess.get("transport") is transport:
|
||||||
|
sess["transport"] = server._stdio_transport
|
||||||
|
|
||||||
|
try:
|
||||||
|
await ws.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
@ -11,16 +11,22 @@ Browser-based dashboard for managing Hermes Agent configuration, API keys, and m
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Start the backend API server
|
# Pin a shared dev token so Vite (5173) and FastAPI (9119) agree.
|
||||||
cd ../
|
# Without this, the SPA can't authenticate against the backend in dev mode.
|
||||||
python -m hermes_cli.main web --no-open
|
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/
|
cd web/
|
||||||
npm run dev
|
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
|
## Build
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import { Cell, Grid, SelectionSwitcher, Typography } from "@nous-research/ui";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Backdrop } from "@/components/Backdrop";
|
import { Backdrop } from "@/components/Backdrop";
|
||||||
import StatusPage from "@/pages/StatusPage";
|
import StatusPage from "@/pages/StatusPage";
|
||||||
|
import ChatPage from "@/pages/ChatPage";
|
||||||
import ConfigPage from "@/pages/ConfigPage";
|
import ConfigPage from "@/pages/ConfigPage";
|
||||||
import EnvPage from "@/pages/EnvPage";
|
import EnvPage from "@/pages/EnvPage";
|
||||||
import SessionsPage from "@/pages/SessionsPage";
|
import SessionsPage from "@/pages/SessionsPage";
|
||||||
|
|
@ -45,6 +46,7 @@ import { useTheme } from "@/themes";
|
||||||
* `path` in `BUILTIN_NAV` so `/path` lookups stay consistent. */
|
* `path` in `BUILTIN_NAV` so `/path` lookups stay consistent. */
|
||||||
const BUILTIN_ROUTES: Record<string, React.ComponentType> = {
|
const BUILTIN_ROUTES: Record<string, React.ComponentType> = {
|
||||||
"/": StatusPage,
|
"/": StatusPage,
|
||||||
|
"/chat": ChatPage,
|
||||||
"/sessions": SessionsPage,
|
"/sessions": SessionsPage,
|
||||||
"/analytics": AnalyticsPage,
|
"/analytics": AnalyticsPage,
|
||||||
"/logs": LogsPage,
|
"/logs": LogsPage,
|
||||||
|
|
@ -56,6 +58,7 @@ const BUILTIN_ROUTES: Record<string, React.ComponentType> = {
|
||||||
|
|
||||||
const BUILTIN_NAV: NavItem[] = [
|
const BUILTIN_NAV: NavItem[] = [
|
||||||
{ path: "/", labelKey: "status", label: "Status", icon: Activity },
|
{ path: "/", labelKey: "status", label: "Status", icon: Activity },
|
||||||
|
{ path: "/chat", labelKey: "chat", label: "Chat", icon: Terminal },
|
||||||
{
|
{
|
||||||
path: "/sessions",
|
path: "/sessions",
|
||||||
labelKey: "sessions",
|
labelKey: "sessions",
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,50 @@
|
||||||
import { useMemo } from "react";
|
import { useMemo, type ReactNode } from "react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lightweight markdown renderer for LLM output.
|
* Lightweight markdown renderer for LLM output.
|
||||||
* Handles: code blocks, inline code, bold, italic, headers, links, lists, horizontal rules.
|
* Handles: code blocks, inline code, bold, italic, headers, links, lists, horizontal rules.
|
||||||
* NOT a full CommonMark parser — optimized for typical assistant message patterns.
|
* NOT a full CommonMark parser — optimized for typical assistant message patterns.
|
||||||
|
*
|
||||||
|
* `streaming` renders a blinking caret at the tail of the last block so it
|
||||||
|
* appears to hug the final character instead of wrapping onto a new line
|
||||||
|
* after a block element (paragraph/list/code/…).
|
||||||
*/
|
*/
|
||||||
export function Markdown({ content, highlightTerms }: { content: string; highlightTerms?: string[] }) {
|
export function Markdown({
|
||||||
|
content,
|
||||||
|
highlightTerms,
|
||||||
|
streaming,
|
||||||
|
}: {
|
||||||
|
content: string;
|
||||||
|
highlightTerms?: string[];
|
||||||
|
streaming?: boolean;
|
||||||
|
}) {
|
||||||
const blocks = useMemo(() => parseBlocks(content), [content]);
|
const blocks = useMemo(() => parseBlocks(content), [content]);
|
||||||
|
const caret = streaming ? <StreamingCaret /> : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="text-sm text-foreground leading-relaxed space-y-2">
|
<div className="text-sm text-foreground leading-relaxed space-y-2">
|
||||||
{blocks.map((block, i) => (
|
{blocks.map((block, i) => (
|
||||||
<Block key={i} block={block} highlightTerms={highlightTerms} />
|
<Block
|
||||||
|
key={i}
|
||||||
|
block={block}
|
||||||
|
highlightTerms={highlightTerms}
|
||||||
|
caret={caret && i === blocks.length - 1 ? caret : null}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
|
{blocks.length === 0 && caret}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StreamingCaret() {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="inline-block w-[0.5em] h-[1em] ml-0.5 align-[-0.15em] bg-foreground/50 animate-pulse"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Types */
|
/* Types */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
@ -58,7 +86,11 @@ function parseBlocks(text: string): BlockNode[] {
|
||||||
// Heading
|
// Heading
|
||||||
const headingMatch = line.match(/^(#{1,4})\s+(.+)/);
|
const headingMatch = line.match(/^(#{1,4})\s+(.+)/);
|
||||||
if (headingMatch) {
|
if (headingMatch) {
|
||||||
blocks.push({ type: "heading", level: headingMatch[1].length, content: headingMatch[2] });
|
blocks.push({
|
||||||
|
type: "heading",
|
||||||
|
level: headingMatch[1].length,
|
||||||
|
content: headingMatch[2],
|
||||||
|
});
|
||||||
i++;
|
i++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -124,12 +156,23 @@ function parseBlocks(text: string): BlockNode[] {
|
||||||
/* Block renderer */
|
/* Block renderer */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
function Block({ block, highlightTerms }: { block: BlockNode; highlightTerms?: string[] }) {
|
function Block({
|
||||||
|
block,
|
||||||
|
highlightTerms,
|
||||||
|
caret,
|
||||||
|
}: {
|
||||||
|
block: BlockNode;
|
||||||
|
highlightTerms?: string[];
|
||||||
|
caret?: ReactNode;
|
||||||
|
}) {
|
||||||
switch (block.type) {
|
switch (block.type) {
|
||||||
case "code":
|
case "code":
|
||||||
return (
|
return (
|
||||||
<pre className="bg-secondary/60 border border-border px-3 py-2.5 text-xs font-mono leading-relaxed overflow-x-auto">
|
<pre className="bg-secondary/60 border border-border px-3 py-2.5 text-xs font-mono leading-relaxed overflow-x-auto">
|
||||||
<code>{block.content}</code>
|
<code>
|
||||||
|
{block.content}
|
||||||
|
{caret}
|
||||||
|
</code>
|
||||||
</pre>
|
</pre>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -141,25 +184,46 @@ function Block({ block, highlightTerms }: { block: BlockNode; highlightTerms?: s
|
||||||
h3: "text-sm font-semibold",
|
h3: "text-sm font-semibold",
|
||||||
h4: "text-sm font-medium",
|
h4: "text-sm font-medium",
|
||||||
};
|
};
|
||||||
return <Tag className={sizes[Tag]}><InlineContent text={block.content} highlightTerms={highlightTerms} /></Tag>;
|
return (
|
||||||
|
<Tag className={sizes[Tag]}>
|
||||||
|
<InlineContent text={block.content} highlightTerms={highlightTerms} />
|
||||||
|
{caret}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
case "hr":
|
case "hr":
|
||||||
return <hr className="border-border" />;
|
return (
|
||||||
|
<>
|
||||||
|
<hr className="border-border" />
|
||||||
|
{caret}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
case "list": {
|
case "list": {
|
||||||
const Tag = block.ordered ? "ol" : "ul";
|
const Tag = block.ordered ? "ol" : "ul";
|
||||||
|
const last = block.items.length - 1;
|
||||||
return (
|
return (
|
||||||
<Tag className={`space-y-0.5 ${block.ordered ? "list-decimal" : "list-disc"} pl-5 text-sm`}>
|
<Tag
|
||||||
|
className={`space-y-0.5 ${block.ordered ? "list-decimal" : "list-disc"} pl-5 text-sm`}
|
||||||
|
>
|
||||||
{block.items.map((item, i) => (
|
{block.items.map((item, i) => (
|
||||||
<li key={i}><InlineContent text={item} highlightTerms={highlightTerms} /></li>
|
<li key={i}>
|
||||||
|
<InlineContent text={item} highlightTerms={highlightTerms} />
|
||||||
|
{i === last ? caret : null}
|
||||||
|
</li>
|
||||||
))}
|
))}
|
||||||
</Tag>
|
</Tag>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
case "paragraph":
|
case "paragraph":
|
||||||
return <p><InlineContent text={block.content} highlightTerms={highlightTerms} /></p>;
|
return (
|
||||||
|
<p>
|
||||||
|
<InlineContent text={block.content} highlightTerms={highlightTerms} />
|
||||||
|
{caret}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -178,7 +242,8 @@ type InlineNode =
|
||||||
function parseInline(text: string): InlineNode[] {
|
function parseInline(text: string): InlineNode[] {
|
||||||
const nodes: InlineNode[] = [];
|
const nodes: InlineNode[] = [];
|
||||||
// Pattern priority: code > link > bold > italic > bare URL > line break
|
// Pattern priority: code > link > bold > italic > bare URL > line break
|
||||||
const pattern = /(`[^`]+`)|(\[([^\]]+)\]\(([^)]+)\))|(\*\*([^*]+)\*\*)|(\*([^*]+)\*)|(\bhttps?:\/\/[^\s<>)\]]+)|(\n)/g;
|
const pattern =
|
||||||
|
/(`[^`]+`)|(\[([^\]]+)\]\(([^)]+)\))|(\*\*([^*]+)\*\*)|(\*([^*]+)\*)|(\bhttps?:\/\/[^\s<>)\]]+)|(\n)/g;
|
||||||
let lastIndex = 0;
|
let lastIndex = 0;
|
||||||
let match: RegExpExecArray | null;
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
|
|
@ -217,7 +282,13 @@ function parseInline(text: string): InlineNode[] {
|
||||||
return nodes;
|
return nodes;
|
||||||
}
|
}
|
||||||
|
|
||||||
function InlineContent({ text, highlightTerms }: { text: string; highlightTerms?: string[] }) {
|
function InlineContent({
|
||||||
|
text,
|
||||||
|
highlightTerms,
|
||||||
|
}: {
|
||||||
|
text: string;
|
||||||
|
highlightTerms?: string[];
|
||||||
|
}) {
|
||||||
const nodes = useMemo(() => parseInline(text), [text]);
|
const nodes = useMemo(() => parseInline(text), [text]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -225,17 +296,34 @@ function InlineContent({ text, highlightTerms }: { text: string; highlightTerms?
|
||||||
{nodes.map((node, i) => {
|
{nodes.map((node, i) => {
|
||||||
switch (node.type) {
|
switch (node.type) {
|
||||||
case "text":
|
case "text":
|
||||||
return <HighlightedText key={i} text={node.content} terms={highlightTerms} />;
|
return (
|
||||||
|
<HighlightedText
|
||||||
|
key={i}
|
||||||
|
text={node.content}
|
||||||
|
terms={highlightTerms}
|
||||||
|
/>
|
||||||
|
);
|
||||||
case "code":
|
case "code":
|
||||||
return (
|
return (
|
||||||
<code key={i} className="bg-secondary/60 px-1.5 py-0.5 text-xs font-mono text-primary/90">
|
<code
|
||||||
|
key={i}
|
||||||
|
className="bg-secondary/60 px-1.5 py-0.5 text-xs font-mono text-primary/90"
|
||||||
|
>
|
||||||
{node.content}
|
{node.content}
|
||||||
</code>
|
</code>
|
||||||
);
|
);
|
||||||
case "bold":
|
case "bold":
|
||||||
return <strong key={i} className="font-semibold"><HighlightedText text={node.content} terms={highlightTerms} /></strong>;
|
return (
|
||||||
|
<strong key={i} className="font-semibold">
|
||||||
|
<HighlightedText text={node.content} terms={highlightTerms} />
|
||||||
|
</strong>
|
||||||
|
);
|
||||||
case "italic":
|
case "italic":
|
||||||
return <em key={i}><HighlightedText text={node.content} terms={highlightTerms} /></em>;
|
return (
|
||||||
|
<em key={i}>
|
||||||
|
<HighlightedText text={node.content} terms={highlightTerms} />
|
||||||
|
</em>
|
||||||
|
);
|
||||||
case "link":
|
case "link":
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
|
|
@ -269,10 +357,12 @@ function HighlightedText({ text, terms }: { text: string; terms?: string[] }) {
|
||||||
<>
|
<>
|
||||||
{parts.map((part, i) =>
|
{parts.map((part, i) =>
|
||||||
regex.test(part) ? (
|
regex.test(part) ? (
|
||||||
<mark key={i} className="bg-warning/30 text-warning px-0.5">{part}</mark>
|
<mark key={i} className="bg-warning/30 text-warning px-0.5">
|
||||||
|
{part}
|
||||||
|
</mark>
|
||||||
) : (
|
) : (
|
||||||
<span key={i}>{part}</span>
|
<span key={i}>{part}</span>
|
||||||
)
|
),
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
392
web/src/components/ModelPickerDialog.tsx
Normal file
392
web/src/components/ModelPickerDialog.tsx
Normal file
|
|
@ -0,0 +1,392 @@
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import type { GatewayClient } from "@/lib/gatewayClient";
|
||||||
|
import { Check, Loader2, Search, X } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two-stage model picker modal.
|
||||||
|
*
|
||||||
|
* Mirrors ui-tui/src/components/modelPicker.tsx:
|
||||||
|
* Stage 1: pick provider (authenticated providers only)
|
||||||
|
* Stage 2: pick model within that provider
|
||||||
|
*
|
||||||
|
* On confirm, emits `/model <model> --provider <slug> [--global]` through
|
||||||
|
* the parent callback so ChatPage can dispatch it via the existing slash
|
||||||
|
* pipeline. That keeps persistence + actual switch logic in one place.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface ModelOptionProvider {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
models?: string[];
|
||||||
|
total_models?: number;
|
||||||
|
is_current?: boolean;
|
||||||
|
warning?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModelOptionsResponse {
|
||||||
|
model?: string;
|
||||||
|
provider?: string;
|
||||||
|
providers?: ModelOptionProvider[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
gw: GatewayClient;
|
||||||
|
sessionId: string;
|
||||||
|
onClose(): void;
|
||||||
|
/** Parent runs the resulting slash command through slashExec. */
|
||||||
|
onSubmit(slashCommand: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelPickerDialog({ gw, sessionId, onClose, onSubmit }: Props) {
|
||||||
|
const [providers, setProviders] = useState<ModelOptionProvider[]>([]);
|
||||||
|
const [currentModel, setCurrentModel] = useState("");
|
||||||
|
const [currentProviderSlug, setCurrentProviderSlug] = useState("");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [selectedSlug, setSelectedSlug] = useState("");
|
||||||
|
const [selectedModel, setSelectedModel] = useState("");
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [persistGlobal, setPersistGlobal] = useState(false);
|
||||||
|
const closedRef = useRef(false);
|
||||||
|
|
||||||
|
// Load providers + models on open.
|
||||||
|
useEffect(() => {
|
||||||
|
closedRef.current = false;
|
||||||
|
|
||||||
|
gw.request<ModelOptionsResponse>(
|
||||||
|
"model.options",
|
||||||
|
sessionId ? { session_id: sessionId } : {},
|
||||||
|
)
|
||||||
|
.then((r) => {
|
||||||
|
if (closedRef.current) return;
|
||||||
|
const next = r?.providers ?? [];
|
||||||
|
setProviders(next);
|
||||||
|
setCurrentModel(String(r?.model ?? ""));
|
||||||
|
setCurrentProviderSlug(String(r?.provider ?? ""));
|
||||||
|
setSelectedSlug(
|
||||||
|
(next.find((p) => p.is_current) ?? next[0])?.slug ?? "",
|
||||||
|
);
|
||||||
|
setSelectedModel("");
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (closedRef.current) return;
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
closedRef.current = true;
|
||||||
|
};
|
||||||
|
}, [gw, sessionId]);
|
||||||
|
|
||||||
|
// Esc closes.
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const selectedProvider = useMemo(
|
||||||
|
() => providers.find((p) => p.slug === selectedSlug) ?? null,
|
||||||
|
[providers, selectedSlug],
|
||||||
|
);
|
||||||
|
|
||||||
|
const models = useMemo(
|
||||||
|
() => selectedProvider?.models ?? [],
|
||||||
|
[selectedProvider],
|
||||||
|
);
|
||||||
|
|
||||||
|
const needle = query.trim().toLowerCase();
|
||||||
|
|
||||||
|
const filteredProviders = useMemo(
|
||||||
|
() =>
|
||||||
|
!needle
|
||||||
|
? providers
|
||||||
|
: providers.filter(
|
||||||
|
(p) =>
|
||||||
|
p.name.toLowerCase().includes(needle) ||
|
||||||
|
p.slug.toLowerCase().includes(needle) ||
|
||||||
|
(p.models ?? []).some((m) => m.toLowerCase().includes(needle)),
|
||||||
|
),
|
||||||
|
[providers, needle],
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredModels = useMemo(
|
||||||
|
() =>
|
||||||
|
!needle ? models : models.filter((m) => m.toLowerCase().includes(needle)),
|
||||||
|
[models, needle],
|
||||||
|
);
|
||||||
|
|
||||||
|
const canConfirm = !!selectedProvider && !!selectedModel;
|
||||||
|
|
||||||
|
const confirm = () => {
|
||||||
|
if (!canConfirm) return;
|
||||||
|
const global = persistGlobal ? " --global" : "";
|
||||||
|
onSubmit(
|
||||||
|
`/model ${selectedModel} --provider ${selectedProvider.slug}${global}`,
|
||||||
|
);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-100 flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
|
||||||
|
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="model-picker-title"
|
||||||
|
>
|
||||||
|
<div className="relative w-full max-w-3xl max-h-[80vh] border border-border bg-card shadow-2xl flex flex-col">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute right-3 top-3 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<header className="p-5 pb-3 border-b border-border">
|
||||||
|
<h2
|
||||||
|
id="model-picker-title"
|
||||||
|
className="font-display text-base tracking-wider uppercase"
|
||||||
|
>
|
||||||
|
Switch Model
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 font-mono">
|
||||||
|
current: {currentModel || "(unknown)"}
|
||||||
|
{currentProviderSlug && ` · ${currentProviderSlug}`}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="px-5 pt-3 pb-2 border-b border-border">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
placeholder="Filter providers and models…"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
className="pl-7 h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0 grid grid-cols-[200px_1fr] overflow-hidden">
|
||||||
|
<ProviderColumn
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
providers={filteredProviders}
|
||||||
|
total={providers.length}
|
||||||
|
selectedSlug={selectedSlug}
|
||||||
|
query={needle}
|
||||||
|
onSelect={(slug) => {
|
||||||
|
setSelectedSlug(slug);
|
||||||
|
setSelectedModel("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ModelColumn
|
||||||
|
provider={selectedProvider}
|
||||||
|
models={filteredModels}
|
||||||
|
allModels={models}
|
||||||
|
selectedModel={selectedModel}
|
||||||
|
currentModel={currentModel}
|
||||||
|
currentProviderSlug={currentProviderSlug}
|
||||||
|
onSelect={setSelectedModel}
|
||||||
|
onConfirm={(m) => {
|
||||||
|
setSelectedModel(m);
|
||||||
|
// Confirm on next tick so state settles.
|
||||||
|
window.setTimeout(confirm, 0);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="border-t border-border p-3 flex items-center justify-between gap-3 flex-wrap">
|
||||||
|
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={persistGlobal}
|
||||||
|
onChange={(e) => setPersistGlobal(e.target.checked)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
/>
|
||||||
|
Persist globally (otherwise this session only)
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 ml-auto">
|
||||||
|
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={confirm} disabled={!canConfirm}>
|
||||||
|
Switch
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Provider column */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function ProviderColumn({
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
providers,
|
||||||
|
total,
|
||||||
|
selectedSlug,
|
||||||
|
query,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
providers: ModelOptionProvider[];
|
||||||
|
total: number;
|
||||||
|
selectedSlug: string;
|
||||||
|
query: string;
|
||||||
|
onSelect(slug: string): void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="border-r border-border overflow-y-auto">
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center gap-2 p-4 text-xs text-muted-foreground">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" /> loading…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <div className="p-4 text-xs text-destructive">{error}</div>}
|
||||||
|
|
||||||
|
{!loading && !error && providers.length === 0 && (
|
||||||
|
<div className="p-4 text-xs text-muted-foreground italic">
|
||||||
|
{query
|
||||||
|
? "no matches"
|
||||||
|
: total === 0
|
||||||
|
? "no authenticated providers"
|
||||||
|
: "no matches"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{providers.map((p) => {
|
||||||
|
const active = p.slug === selectedSlug;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p.slug}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(p.slug)}
|
||||||
|
className={`w-full text-left px-3 py-2 text-xs border-l-2 transition-colors cursor-pointer flex items-start gap-2 ${
|
||||||
|
active
|
||||||
|
? "bg-primary/10 border-l-primary text-foreground"
|
||||||
|
: "border-l-transparent text-muted-foreground hover:text-foreground hover:bg-muted/40"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="font-medium truncate">{p.name}</span>
|
||||||
|
{p.is_current && <CurrentTag />}
|
||||||
|
</div>
|
||||||
|
<div className="text-[0.65rem] text-muted-foreground/80 font-mono truncate">
|
||||||
|
{p.slug} · {p.total_models ?? p.models?.length ?? 0} models
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Model column */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function ModelColumn({
|
||||||
|
provider,
|
||||||
|
models,
|
||||||
|
allModels,
|
||||||
|
selectedModel,
|
||||||
|
currentModel,
|
||||||
|
currentProviderSlug,
|
||||||
|
onSelect,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
provider: ModelOptionProvider | null;
|
||||||
|
models: string[];
|
||||||
|
allModels: string[];
|
||||||
|
selectedModel: string;
|
||||||
|
currentModel: string;
|
||||||
|
currentProviderSlug: string;
|
||||||
|
onSelect(model: string): void;
|
||||||
|
onConfirm(model: string): void;
|
||||||
|
}) {
|
||||||
|
if (!provider) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-y-auto">
|
||||||
|
<div className="p-4 text-xs text-muted-foreground italic">
|
||||||
|
pick a provider →
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-y-auto">
|
||||||
|
{provider.warning && (
|
||||||
|
<div className="p-3 text-xs text-destructive border-b border-border">
|
||||||
|
{provider.warning}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{models.length === 0 ? (
|
||||||
|
<div className="p-4 text-xs text-muted-foreground italic">
|
||||||
|
{allModels.length
|
||||||
|
? "no models match your filter"
|
||||||
|
: "no models listed for this provider"}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
models.map((m) => {
|
||||||
|
const active = m === selectedModel;
|
||||||
|
const isCurrent =
|
||||||
|
m === currentModel && provider.slug === currentProviderSlug;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(m)}
|
||||||
|
onDoubleClick={() => onConfirm(m)}
|
||||||
|
className={`w-full text-left px-3 py-1.5 text-xs font-mono transition-colors cursor-pointer flex items-center gap-2 ${
|
||||||
|
active
|
||||||
|
? "bg-primary/15 text-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={`h-3 w-3 shrink-0 ${active ? "text-primary" : "text-transparent"}`}
|
||||||
|
/>
|
||||||
|
<span className="flex-1 truncate">{m}</span>
|
||||||
|
{isCurrent && <CurrentTag />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CurrentTag() {
|
||||||
|
return (
|
||||||
|
<span className="text-[0.6rem] uppercase tracking-wider text-primary/80 shrink-0">
|
||||||
|
current
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
174
web/src/components/SlashPopover.tsx
Normal file
174
web/src/components/SlashPopover.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
import type { GatewayClient } from "@/lib/gatewayClient";
|
||||||
|
import { ChevronRight } from "lucide-react";
|
||||||
|
import {
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slash-command autocomplete popover, rendered above the composer in ChatPage.
|
||||||
|
* Mirrors the completion UX of the Ink TUI — type `/`, see matching commands,
|
||||||
|
* arrow keys or click to select, Tab to apply, Enter to submit.
|
||||||
|
*
|
||||||
|
* The parent owns all keyboard handling via `ref.handleKey`, which returns
|
||||||
|
* true when the popover consumed the event, so the composer's Enter/arrow
|
||||||
|
* logic stays in one place.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface CompletionItem {
|
||||||
|
display: string;
|
||||||
|
text: string;
|
||||||
|
meta?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SlashPopoverHandle {
|
||||||
|
/** Returns true if the key was consumed by the popover. */
|
||||||
|
handleKey(e: React.KeyboardEvent<HTMLTextAreaElement>): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
input: string;
|
||||||
|
gw: GatewayClient | null;
|
||||||
|
onApply(nextInput: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompletionResponse {
|
||||||
|
items?: CompletionItem[];
|
||||||
|
replace_from?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 60;
|
||||||
|
|
||||||
|
export const SlashPopover = forwardRef<SlashPopoverHandle, Props>(
|
||||||
|
function SlashPopover({ input, gw, onApply }, ref) {
|
||||||
|
const [items, setItems] = useState<CompletionItem[]>([]);
|
||||||
|
const [selected, setSelected] = useState(0);
|
||||||
|
const [replaceFrom, setReplaceFrom] = useState(1);
|
||||||
|
const lastInputRef = useRef<string>("");
|
||||||
|
|
||||||
|
// Debounced completion fetch. We never clear `items` in the effect body
|
||||||
|
// (doing so would flag react-hooks/set-state-in-effect); instead the
|
||||||
|
// render guard below hides stale items once the input stops matching.
|
||||||
|
useEffect(() => {
|
||||||
|
const trimmed = input ?? "";
|
||||||
|
|
||||||
|
if (!gw || !trimmed.startsWith("/") || trimmed === lastInputRef.current) {
|
||||||
|
if (!trimmed.startsWith("/")) lastInputRef.current = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastInputRef.current = trimmed;
|
||||||
|
|
||||||
|
const timer = window.setTimeout(async () => {
|
||||||
|
if (lastInputRef.current !== trimmed) return;
|
||||||
|
try {
|
||||||
|
const r = await gw.request<CompletionResponse>("complete.slash", {
|
||||||
|
text: trimmed,
|
||||||
|
});
|
||||||
|
if (lastInputRef.current !== trimmed) return;
|
||||||
|
setItems(r?.items ?? []);
|
||||||
|
setReplaceFrom(r?.replace_from ?? 1);
|
||||||
|
setSelected(0);
|
||||||
|
} catch {
|
||||||
|
if (lastInputRef.current === trimmed) setItems([]);
|
||||||
|
}
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [input, gw]);
|
||||||
|
|
||||||
|
const apply = useCallback(
|
||||||
|
(item: CompletionItem) => {
|
||||||
|
onApply(input.slice(0, replaceFrom) + item.text);
|
||||||
|
},
|
||||||
|
[input, replaceFrom, onApply],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Only consume keys when the popover is actually visible. Stale items from
|
||||||
|
// a previous slash prefix are ignored once the user deletes the "/".
|
||||||
|
const visible = items.length > 0 && input.startsWith("/");
|
||||||
|
|
||||||
|
useImperativeHandle(
|
||||||
|
ref,
|
||||||
|
() => ({
|
||||||
|
handleKey: (e) => {
|
||||||
|
if (!visible) return false;
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case "ArrowDown":
|
||||||
|
e.preventDefault();
|
||||||
|
setSelected((s) => (s + 1) % items.length);
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case "ArrowUp":
|
||||||
|
e.preventDefault();
|
||||||
|
setSelected((s) => (s - 1 + items.length) % items.length);
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case "Tab": {
|
||||||
|
e.preventDefault();
|
||||||
|
const item = items[selected];
|
||||||
|
if (item) apply(item);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "Escape":
|
||||||
|
e.preventDefault();
|
||||||
|
setItems([]);
|
||||||
|
return true;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[visible, items, selected, apply],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute bottom-full left-0 right-0 mb-2 max-h-64 overflow-y-auto rounded-md border border-border bg-popover shadow-xl text-sm"
|
||||||
|
role="listbox"
|
||||||
|
>
|
||||||
|
{items.map((it, i) => {
|
||||||
|
const active = i === selected;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${it.text}-${i}`}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={active}
|
||||||
|
onMouseEnter={() => setSelected(i)}
|
||||||
|
onClick={() => apply(it)}
|
||||||
|
className={`w-full flex items-center gap-2 px-3 py-1.5 text-left cursor-pointer transition-colors ${
|
||||||
|
active
|
||||||
|
? "bg-primary/10 text-foreground"
|
||||||
|
: "text-muted-foreground hover:bg-muted/60"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ChevronRight
|
||||||
|
className={`h-3 w-3 shrink-0 ${active ? "text-primary" : "text-transparent"}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span className="font-mono text-xs shrink-0 truncate">
|
||||||
|
{it.display}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{it.meta && (
|
||||||
|
<span className="text-[0.7rem] text-muted-foreground/70 truncate ml-auto">
|
||||||
|
{it.meta}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
228
web/src/components/ToolCall.tsx
Normal file
228
web/src/components/ToolCall.tsx
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
Check,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
Zap,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expandable tool call row — the web equivalent of Ink's ToolTrail node.
|
||||||
|
*
|
||||||
|
* Renders one `tool.start` + `tool.complete` pair (plus any `tool.progress`
|
||||||
|
* in between) as a single collapsible item in the transcript:
|
||||||
|
*
|
||||||
|
* ▸ ● read_file(path=/foo) 2.3s
|
||||||
|
*
|
||||||
|
* Click the header to reveal a preformatted body with context (args), the
|
||||||
|
* streaming preview (while running), and the final summary or error. Error
|
||||||
|
* rows auto-expand so failures aren't silently collapsed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ToolEntry {
|
||||||
|
kind: "tool";
|
||||||
|
id: string;
|
||||||
|
tool_id: string;
|
||||||
|
name: string;
|
||||||
|
context?: string;
|
||||||
|
preview?: string;
|
||||||
|
summary?: string;
|
||||||
|
error?: string;
|
||||||
|
inline_diff?: string;
|
||||||
|
status: "running" | "done" | "error";
|
||||||
|
startedAt: number;
|
||||||
|
completedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_TONE: Record<ToolEntry["status"], string> = {
|
||||||
|
running: "border-primary/40 bg-primary/[0.04]",
|
||||||
|
done: "border-border bg-muted/20",
|
||||||
|
error: "border-destructive/50 bg-destructive/[0.04]",
|
||||||
|
};
|
||||||
|
|
||||||
|
const BULLET_TONE: Record<ToolEntry["status"], string> = {
|
||||||
|
running: "text-primary",
|
||||||
|
done: "text-primary/80",
|
||||||
|
error: "text-destructive",
|
||||||
|
};
|
||||||
|
|
||||||
|
const TICK_MS = 500;
|
||||||
|
|
||||||
|
export function ToolCall({ tool }: { tool: ToolEntry }) {
|
||||||
|
// `open` is derived: errors default-expanded, everything else collapsed.
|
||||||
|
// `null` means "follow the default"; any explicit bool is the user's override.
|
||||||
|
// This lets a running tool flip to expanded automatically when it errors,
|
||||||
|
// without mirroring state in an effect.
|
||||||
|
const [userOverride, setUserOverride] = useState<boolean | null>(null);
|
||||||
|
const open = userOverride ?? tool.status === "error";
|
||||||
|
|
||||||
|
// Tick `now` while the tool is running so the elapsed label updates live.
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
useEffect(() => {
|
||||||
|
if (tool.status !== "running") return;
|
||||||
|
const id = window.setInterval(() => setNow(() => Date.now()), TICK_MS);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [tool.status]);
|
||||||
|
|
||||||
|
// Historical tools (hydrated from session.resume) signal missing timestamps
|
||||||
|
// with `startedAt === 0`; we hide the elapsed badge for those rather than
|
||||||
|
// rendering a misleading "0ms".
|
||||||
|
const hasTimestamps = tool.startedAt > 0;
|
||||||
|
const elapsed = hasTimestamps
|
||||||
|
? fmtElapsed((tool.completedAt ?? now) - tool.startedAt)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const hasBody = !!(
|
||||||
|
tool.context ||
|
||||||
|
tool.preview ||
|
||||||
|
tool.summary ||
|
||||||
|
tool.error ||
|
||||||
|
tool.inline_diff
|
||||||
|
);
|
||||||
|
|
||||||
|
const Chevron = open ? ChevronDown : ChevronRight;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`rounded-md border overflow-hidden ${STATUS_TONE[tool.status]}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setUserOverride(!open)}
|
||||||
|
disabled={!hasBody}
|
||||||
|
aria-expanded={open}
|
||||||
|
className="w-full flex items-center gap-2 px-2.5 py-1.5 text-left text-xs hover:bg-foreground/2 disabled:cursor-default cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
{hasBody ? (
|
||||||
|
<Chevron className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<span className="w-3 shrink-0" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Zap className={`h-3 w-3 shrink-0 ${BULLET_TONE[tool.status]}`} />
|
||||||
|
|
||||||
|
<span className="font-mono font-medium shrink-0">{tool.name}</span>
|
||||||
|
|
||||||
|
<span className="font-mono text-muted-foreground/80 truncate min-w-0 flex-1">
|
||||||
|
{tool.context ?? ""}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{tool.status === "running" && (
|
||||||
|
<span
|
||||||
|
className="inline-block h-2 w-2 rounded-full bg-primary animate-pulse shrink-0"
|
||||||
|
title="running"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tool.status === "error" && (
|
||||||
|
<AlertCircle
|
||||||
|
className="h-3 w-3 shrink-0 text-destructive"
|
||||||
|
aria-label="error"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tool.status === "done" && (
|
||||||
|
<Check
|
||||||
|
className="h-3 w-3 shrink-0 text-primary/80"
|
||||||
|
aria-label="done"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{elapsed && (
|
||||||
|
<span className="font-mono text-[0.65rem] text-muted-foreground tabular-nums shrink-0">
|
||||||
|
{elapsed}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && hasBody && (
|
||||||
|
<div className="border-t border-border/60 px-3 py-2 space-y-2 text-xs font-mono">
|
||||||
|
{tool.context && <Section label="context">{tool.context}</Section>}
|
||||||
|
|
||||||
|
{tool.preview && tool.status === "running" && (
|
||||||
|
<Section label="streaming">
|
||||||
|
{tool.preview}
|
||||||
|
<span className="inline-block w-1.5 h-3 align-middle bg-foreground/40 ml-0.5 animate-pulse" />
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tool.inline_diff && (
|
||||||
|
<Section label="diff">
|
||||||
|
<pre className="whitespace-pre overflow-x-auto text-[0.7rem] leading-snug">
|
||||||
|
{colorizeDiff(tool.inline_diff)}
|
||||||
|
</pre>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tool.summary && (
|
||||||
|
<Section label="result">
|
||||||
|
<span className="text-foreground/90 whitespace-pre-wrap">
|
||||||
|
{tool.summary}
|
||||||
|
</span>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tool.error && (
|
||||||
|
<Section label="error" tone="error">
|
||||||
|
<span className="text-destructive whitespace-pre-wrap">
|
||||||
|
{tool.error}
|
||||||
|
</span>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
tone,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
tone?: "error";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<span
|
||||||
|
className={`uppercase tracking-wider text-[0.6rem] shrink-0 w-14 pt-0.5 ${
|
||||||
|
tone === "error" ? "text-destructive/80" : "text-muted-foreground/60"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0 text-muted-foreground">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtElapsed(ms: number): string {
|
||||||
|
const sec = Math.max(0, ms) / 1000;
|
||||||
|
if (sec < 1) return `${Math.round(ms)}ms`;
|
||||||
|
if (sec < 10) return `${sec.toFixed(1)}s`;
|
||||||
|
if (sec < 60) return `${Math.round(sec)}s`;
|
||||||
|
|
||||||
|
const m = Math.floor(sec / 60);
|
||||||
|
const s = Math.round(sec % 60);
|
||||||
|
return s ? `${m}m ${s}s` : `${m}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Colorize unified-diff lines for the inline diff section. */
|
||||||
|
function colorizeDiff(diff: string): React.ReactNode {
|
||||||
|
return diff.split("\n").map((line, i) => (
|
||||||
|
<div key={i} className={diffLineClass(line)}>
|
||||||
|
{line || "\u00A0"}
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function diffLineClass(line: string): string {
|
||||||
|
if (line.startsWith("+") && !line.startsWith("+++"))
|
||||||
|
return "text-emerald-500 dark:text-emerald-400";
|
||||||
|
if (line.startsWith("-") && !line.startsWith("---"))
|
||||||
|
return "text-destructive";
|
||||||
|
if (line.startsWith("@@")) return "text-primary";
|
||||||
|
return "text-muted-foreground/80";
|
||||||
|
}
|
||||||
232
web/src/lib/gatewayClient.ts
Normal file
232
web/src/lib/gatewayClient.ts
Normal file
|
|
@ -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<P = unknown> {
|
||||||
|
type: GatewayEventName;
|
||||||
|
session_id?: string;
|
||||||
|
payload?: P;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConnectionState =
|
||||||
|
| "idle"
|
||||||
|
| "connecting"
|
||||||
|
| "open"
|
||||||
|
| "closed"
|
||||||
|
| "error";
|
||||||
|
|
||||||
|
interface Pending {
|
||||||
|
resolve: (v: unknown) => void;
|
||||||
|
reject: (e: Error) => void;
|
||||||
|
timer: ReturnType<typeof setTimeout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_REQUEST_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
|
/** Wildcard listener key: subscribe to every event regardless of type. */
|
||||||
|
const ANY = "*";
|
||||||
|
|
||||||
|
export class GatewayClient {
|
||||||
|
private ws: WebSocket | null = null;
|
||||||
|
private reqId = 0;
|
||||||
|
private pending = new Map<string, Pending>();
|
||||||
|
private listeners = new Map<string, Set<(ev: GatewayEvent) => void>>();
|
||||||
|
private _state: ConnectionState = "idle";
|
||||||
|
private stateListeners = new Set<(s: ConnectionState) => void>();
|
||||||
|
|
||||||
|
get state(): ConnectionState {
|
||||||
|
return this._state;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setState(s: ConnectionState) {
|
||||||
|
if (this._state === s) return;
|
||||||
|
this._state = s;
|
||||||
|
for (const cb of this.stateListeners) cb(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
onState(cb: (s: ConnectionState) => void): () => void {
|
||||||
|
this.stateListeners.add(cb);
|
||||||
|
cb(this._state);
|
||||||
|
return () => this.stateListeners.delete(cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe to a specific event type. Returns an unsubscribe function. */
|
||||||
|
on<P = unknown>(
|
||||||
|
type: GatewayEventName,
|
||||||
|
cb: (ev: GatewayEvent<P>) => void,
|
||||||
|
): () => void {
|
||||||
|
let set = this.listeners.get(type);
|
||||||
|
if (!set) {
|
||||||
|
set = new Set();
|
||||||
|
this.listeners.set(type, set);
|
||||||
|
}
|
||||||
|
set.add(cb as (ev: GatewayEvent) => void);
|
||||||
|
return () => set!.delete(cb as (ev: GatewayEvent) => void);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe to every event (fires after type-specific listeners). */
|
||||||
|
onAny(cb: (ev: GatewayEvent) => void): () => void {
|
||||||
|
return this.on(ANY as GatewayEventName, cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect(token?: string): Promise<void> {
|
||||||
|
if (this._state === "open" || this._state === "connecting") return;
|
||||||
|
this.setState("connecting");
|
||||||
|
|
||||||
|
const resolved = token ?? window.__HERMES_SESSION_TOKEN__ ?? "";
|
||||||
|
if (!resolved) {
|
||||||
|
this.setState("error");
|
||||||
|
throw new Error(
|
||||||
|
"Session token not available — page must be served by the Hermes dashboard",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheme = location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
const ws = new WebSocket(
|
||||||
|
`${scheme}//${location.host}/api/ws?token=${encodeURIComponent(resolved)}`,
|
||||||
|
);
|
||||||
|
this.ws = ws;
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const onOpen = () => {
|
||||||
|
ws.removeEventListener("error", onError);
|
||||||
|
this.setState("open");
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
const onError = () => {
|
||||||
|
ws.removeEventListener("open", onOpen);
|
||||||
|
this.setState("error");
|
||||||
|
reject(new Error("WebSocket connection failed"));
|
||||||
|
};
|
||||||
|
ws.addEventListener("open", onOpen, { once: true });
|
||||||
|
ws.addEventListener("error", onError, { once: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
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<string, unknown>) {
|
||||||
|
const id = msg.id as string | undefined;
|
||||||
|
|
||||||
|
if (id !== undefined && this.pending.has(id)) {
|
||||||
|
const p = this.pending.get(id)!;
|
||||||
|
this.pending.delete(id);
|
||||||
|
clearTimeout(p.timer);
|
||||||
|
|
||||||
|
const err = msg.error as { message?: string } | undefined;
|
||||||
|
if (err) p.reject(new Error(err.message ?? "request failed"));
|
||||||
|
else p.resolve(msg.result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.method !== "event") return;
|
||||||
|
|
||||||
|
const params = (msg.params ?? {}) as GatewayEvent;
|
||||||
|
if (typeof params.type !== "string") return;
|
||||||
|
|
||||||
|
for (const cb of this.listeners.get(params.type) ?? []) cb(params);
|
||||||
|
for (const cb of this.listeners.get(ANY) ?? []) cb(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
private rejectAllPending(err: Error) {
|
||||||
|
for (const p of this.pending.values()) {
|
||||||
|
clearTimeout(p.timer);
|
||||||
|
p.reject(err);
|
||||||
|
}
|
||||||
|
this.pending.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send a JSON-RPC request. Rejects on error response or timeout. */
|
||||||
|
request<T = unknown>(
|
||||||
|
method: string,
|
||||||
|
params: Record<string, unknown> = {},
|
||||||
|
timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
|
||||||
|
): Promise<T> {
|
||||||
|
if (!this.ws || this._state !== "open") {
|
||||||
|
return Promise.reject(
|
||||||
|
new Error(`gateway not connected (state=${this._state})`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = `w${++this.reqId}`;
|
||||||
|
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (this.pending.delete(id)) {
|
||||||
|
reject(new Error(`request timed out: ${method}`));
|
||||||
|
}
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
this.pending.set(id, {
|
||||||
|
resolve: (v) => resolve(v as T),
|
||||||
|
reject,
|
||||||
|
timer,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.ws!.send(JSON.stringify({ jsonrpc: "2.0", id, method, params }));
|
||||||
|
} catch (e) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
this.pending.delete(id);
|
||||||
|
reject(e instanceof Error ? e : new Error(String(e)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__HERMES_SESSION_TOKEN__?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
163
web/src/lib/slashExec.ts
Normal file
163
web/src/lib/slashExec.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
/**
|
||||||
|
* Slash command execution pipeline for the web chat.
|
||||||
|
*
|
||||||
|
* Mirrors the Ink TUI's createSlashHandler.ts:
|
||||||
|
*
|
||||||
|
* 1. Parse the command into `name` + `arg`.
|
||||||
|
* 2. Try `slash.exec` — covers every registry-backed command the terminal
|
||||||
|
* UI knows about (/help, /resume, /compact, /model, …). Output is
|
||||||
|
* rendered into the transcript.
|
||||||
|
* 3. If `slash.exec` errors (command rejected, unknown, or needs client
|
||||||
|
* behaviour), fall back to `command.dispatch` which returns a typed
|
||||||
|
* directive: `exec` | `plugin` | `alias` | `skill` | `send`.
|
||||||
|
* 4. Each directive is dispatched to the appropriate callback.
|
||||||
|
*
|
||||||
|
* Keeping the pipeline here (instead of inline in ChatPage) lets future
|
||||||
|
* clients (SwiftUI, Android) implement the same logic by reading the same
|
||||||
|
* contract.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { GatewayClient } from "@/lib/gatewayClient";
|
||||||
|
|
||||||
|
export interface SlashExecResponse {
|
||||||
|
output?: string;
|
||||||
|
warning?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CommandDispatchResponse =
|
||||||
|
| { type: "exec" | "plugin"; output?: string }
|
||||||
|
| { type: "alias"; target: string }
|
||||||
|
| { type: "skill"; name: string; message?: string }
|
||||||
|
| { type: "send"; message: string };
|
||||||
|
|
||||||
|
export interface SlashExecCallbacks {
|
||||||
|
/** Render a transcript system message. */
|
||||||
|
sys(text: string): void;
|
||||||
|
/** Submit a user message to the agent (prompt.submit). */
|
||||||
|
send(message: string): Promise<void> | void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SlashExecOptions {
|
||||||
|
/** Raw command including the leading slash (e.g. "/model opus-4.6"). */
|
||||||
|
command: string;
|
||||||
|
/** Session id. If empty the call is still issued — some commands are session-less. */
|
||||||
|
sessionId: string;
|
||||||
|
gw: GatewayClient;
|
||||||
|
callbacks: SlashExecCallbacks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SlashExecResult = "done" | "sent" | "error";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a slash command. Returns the terminal state so callers can decide
|
||||||
|
* whether to clear the composer, queue retries, etc.
|
||||||
|
*/
|
||||||
|
export async function executeSlash({
|
||||||
|
command,
|
||||||
|
sessionId,
|
||||||
|
gw,
|
||||||
|
callbacks: { sys, send },
|
||||||
|
}: SlashExecOptions): Promise<SlashExecResult> {
|
||||||
|
const { name, arg } = parseSlash(command);
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
sys("empty slash command");
|
||||||
|
return "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Primary dispatcher.
|
||||||
|
try {
|
||||||
|
const r = await gw.request<SlashExecResponse>("slash.exec", {
|
||||||
|
command: command.replace(/^\/+/, ""),
|
||||||
|
session_id: sessionId,
|
||||||
|
});
|
||||||
|
const body = r?.output || `/${name}: no output`;
|
||||||
|
sys(r?.warning ? `warning: ${r.warning}\n${body}` : body);
|
||||||
|
return "done";
|
||||||
|
} catch {
|
||||||
|
/* fall through to command.dispatch */
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const d = parseCommandDispatch(
|
||||||
|
await gw.request<unknown>("command.dispatch", {
|
||||||
|
name,
|
||||||
|
arg,
|
||||||
|
session_id: sessionId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!d) {
|
||||||
|
sys("error: invalid response: command.dispatch");
|
||||||
|
return "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (d.type) {
|
||||||
|
case "exec":
|
||||||
|
case "plugin":
|
||||||
|
sys(d.output ?? "(no output)");
|
||||||
|
return "done";
|
||||||
|
|
||||||
|
case "alias":
|
||||||
|
return executeSlash({
|
||||||
|
command: `/${d.target}${arg ? ` ${arg}` : ""}`,
|
||||||
|
sessionId,
|
||||||
|
gw,
|
||||||
|
callbacks: { sys, send },
|
||||||
|
});
|
||||||
|
|
||||||
|
case "skill":
|
||||||
|
case "send": {
|
||||||
|
const msg = d.message?.trim() ?? "";
|
||||||
|
if (!msg) {
|
||||||
|
sys(
|
||||||
|
`/${name}: ${d.type === "skill" ? "skill payload missing message" : "empty message"}`,
|
||||||
|
);
|
||||||
|
return "error";
|
||||||
|
}
|
||||||
|
if (d.type === "skill") sys(`⚡ loading skill: ${d.name}`);
|
||||||
|
await send(msg);
|
||||||
|
return "sent";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
sys(`error: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
return "error";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSlash(command: string): { name: string; arg: string } {
|
||||||
|
const m = command.replace(/^\/+/, "").match(/^(\S+)\s*(.*)$/);
|
||||||
|
return m ? { name: m[1], arg: m[2].trim() } : { name: "", arg: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCommandDispatch(raw: unknown): CommandDispatchResponse | null {
|
||||||
|
if (!raw || typeof raw !== "object") return null;
|
||||||
|
|
||||||
|
const r = raw as Record<string, unknown>;
|
||||||
|
const str = (v: unknown) => (typeof v === "string" ? v : undefined);
|
||||||
|
|
||||||
|
switch (r.type) {
|
||||||
|
case "exec":
|
||||||
|
case "plugin":
|
||||||
|
return { type: r.type, output: str(r.output) };
|
||||||
|
|
||||||
|
case "alias":
|
||||||
|
return typeof r.target === "string"
|
||||||
|
? { type: "alias", target: r.target }
|
||||||
|
: null;
|
||||||
|
|
||||||
|
case "skill":
|
||||||
|
return typeof r.name === "string"
|
||||||
|
? { type: "skill", name: r.name, message: str(r.message) }
|
||||||
|
: null;
|
||||||
|
|
||||||
|
case "send":
|
||||||
|
return typeof r.message === "string"
|
||||||
|
? { type: "send", message: r.message }
|
||||||
|
: null;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
752
web/src/pages/ChatPage.tsx
Normal file
752
web/src/pages/ChatPage.tsx
Normal file
|
|
@ -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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionInfo {
|
||||||
|
model?: string;
|
||||||
|
provider?: string;
|
||||||
|
cwd?: string;
|
||||||
|
tools?: Record<string, unknown>;
|
||||||
|
skills?: Record<string, unknown>;
|
||||||
|
credential_warning?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATE_LABEL: Record<ConnectionState, string> = {
|
||||||
|
idle: "idle",
|
||||||
|
connecting: "connecting",
|
||||||
|
open: "connected",
|
||||||
|
closed: "closed",
|
||||||
|
error: "error",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATE_TONE: Record<ConnectionState, string> = {
|
||||||
|
idle: "bg-muted text-muted-foreground",
|
||||||
|
connecting: "bg-primary/10 text-primary",
|
||||||
|
open: "bg-emerald-500/10 text-emerald-500 dark:text-emerald-400",
|
||||||
|
closed: "bg-muted text-muted-foreground",
|
||||||
|
error: "bg-destructive/10 text-destructive",
|
||||||
|
};
|
||||||
|
|
||||||
|
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<GatewayClient | null>(null);
|
||||||
|
const slashRef = useRef<SlashPopoverHandle | null>(null);
|
||||||
|
const transcriptEndRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const resumeId = searchParams.get("resume") ?? "";
|
||||||
|
|
||||||
|
const [connState, setConnState] = useState<ConnectionState>("idle");
|
||||||
|
const [sessionId, setSessionId] = useState("");
|
||||||
|
const [sessionInfo, setSessionInfo] = useState<SessionInfo | null>(null);
|
||||||
|
const [entries, setEntries] = useState<ChatEntry[]>([]);
|
||||||
|
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<TextMessage> = {}) => {
|
||||||
|
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<SessionInfo>("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<SessionResumeResponse>("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.
|
||||||
|
<div className="flex flex-col gap-4 h-[calc(100vh-8rem)] font-courier normal-case">
|
||||||
|
<header className="flex flex-wrap items-center gap-2 justify-between">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Badge className={STATE_TONE[connState]}>
|
||||||
|
<span className="mr-1 h-1.5 w-1.5 rounded-full bg-current inline-block" />
|
||||||
|
{STATE_LABEL[connState]}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
<GoodVibesHeart tick={goodVibesTick} />
|
||||||
|
|
||||||
|
<ModelBadge
|
||||||
|
model={sessionInfo?.model}
|
||||||
|
enabled={canPickModel}
|
||||||
|
onClick={() => setModelPickerOpen(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{sessionId && (
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
navigator.clipboard?.writeText(sessionId).catch(() => {})
|
||||||
|
}
|
||||||
|
className="inline-flex items-center gap-1 font-mono text-[0.7rem] text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||||
|
title="Copy session id"
|
||||||
|
>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
{sessionId}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{busy && (
|
||||||
|
<Button onClick={interrupt} variant="outline" size="sm">
|
||||||
|
<Square className="h-3 w-3 mr-1" fill="currentColor" />
|
||||||
|
Interrupt
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button onClick={bootstrap} variant="ghost" size="sm">
|
||||||
|
<RefreshCw className="h-3 w-3 mr-1" />
|
||||||
|
Reset session
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{connectError && (
|
||||||
|
<Card className="p-3 border-destructive/50 bg-destructive/5 text-sm flex items-start gap-2">
|
||||||
|
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0 text-destructive" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-destructive">
|
||||||
|
Can't connect to gateway
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-xs mt-0.5">
|
||||||
|
{connectError}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card className="flex-1 min-h-0 overflow-hidden flex flex-col">
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 sm:p-6 space-y-3">
|
||||||
|
{entries.length === 0 && !connectError && (
|
||||||
|
<EmptyState connState={connState} cwd={sessionInfo?.cwd} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{entries.map((entry) =>
|
||||||
|
entry.kind === "tool" ? (
|
||||||
|
<ToolCall key={entry.id} tool={entry} />
|
||||||
|
) : (
|
||||||
|
<MessageRow key={entry.id} message={entry} />
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
|
||||||
|
{runtimeError && (
|
||||||
|
<div className="flex items-start gap-2 text-xs text-destructive">
|
||||||
|
<AlertCircle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
|
||||||
|
<span>{runtimeError}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={transcriptEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-border p-3 sm:p-4 relative">
|
||||||
|
<SlashPopover
|
||||||
|
ref={slashRef}
|
||||||
|
input={draft}
|
||||||
|
gw={gwRef.current}
|
||||||
|
onApply={(next) => {
|
||||||
|
setDraft(next);
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-stretch overflow-hidden rounded-md border border-border bg-background/40 transition-colors focus-within:border-foreground/30 focus-within:bg-background/60 focus-within:ring-1 focus-within:ring-foreground/20">
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (slashRef.current?.handleKey(e)) return;
|
||||||
|
if (
|
||||||
|
e.key === "Enter" &&
|
||||||
|
!e.shiftKey &&
|
||||||
|
!e.nativeEvent.isComposing
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
send();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={placeholder}
|
||||||
|
rows={1}
|
||||||
|
className="flex-1 resize-none bg-transparent px-3.5 py-2.5 text-sm leading-relaxed placeholder:text-muted-foreground/50 focus:outline-none min-h-[40px] max-h-[200px] disabled:opacity-50"
|
||||||
|
style={{ fieldSizing: "content" } as React.CSSProperties}
|
||||||
|
disabled={connState !== "open"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={send}
|
||||||
|
disabled={!canSend}
|
||||||
|
aria-label="Send message"
|
||||||
|
className="shrink-0 w-11 flex items-center justify-center border-l border-border bg-foreground/90 text-background transition-colors cursor-pointer hover:bg-foreground active:bg-foreground/80 disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-foreground/90"
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{modelPickerOpen && gwRef.current && (
|
||||||
|
<ModelPickerDialog
|
||||||
|
gw={gwRef.current}
|
||||||
|
sessionId={sessionId}
|
||||||
|
onClose={() => setModelPickerOpen(false)}
|
||||||
|
onSubmit={submitSlash}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Subcomponents */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Port of ui-tui's GoodVibesHeart — a ♥ glows for 650ms in a random palette
|
||||||
|
* colour every time the user says something kind. Same regex, same beat, just
|
||||||
|
* rendered via a Lucide icon instead of an Ink Text node.
|
||||||
|
*/
|
||||||
|
function GoodVibesHeart({ tick }: { tick: number }) {
|
||||||
|
const [active, setActive] = useState(false);
|
||||||
|
const [color, setColor] = useState(HEART_COLORS[0]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tick <= 0) return;
|
||||||
|
setColor(HEART_COLORS[Math.floor(Math.random() * HEART_COLORS.length)]);
|
||||||
|
setActive(true);
|
||||||
|
const id = setTimeout(() => setActive(false), 650);
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
}, [tick]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Heart
|
||||||
|
aria-hidden
|
||||||
|
className={`h-4 w-4 transition-all duration-300 ${
|
||||||
|
active ? "scale-125 opacity-100" : "scale-75 opacity-0"
|
||||||
|
}`}
|
||||||
|
fill={active ? color : "none"}
|
||||||
|
style={{ color }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModelBadge({
|
||||||
|
model,
|
||||||
|
enabled,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
model: string | undefined;
|
||||||
|
enabled: boolean;
|
||||||
|
onClick(): void;
|
||||||
|
}) {
|
||||||
|
const hasModel = !!model;
|
||||||
|
const className = hasModel
|
||||||
|
? "inline-flex items-center gap-1 rounded-md border border-border bg-muted/40 px-2 py-0.5 font-mono text-[0.7rem] hover:bg-muted hover:border-foreground/30 transition-colors cursor-pointer disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
|
: "inline-flex items-center gap-1 rounded-md border border-dashed border-border px-2 py-0.5 font-mono text-[0.7rem] text-muted-foreground hover:text-foreground hover:border-foreground/30 transition-colors cursor-pointer disabled:opacity-60 disabled:cursor-not-allowed";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => enabled && onClick()}
|
||||||
|
disabled={!enabled}
|
||||||
|
title="Click to switch model (same as /model)"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{hasModel ? (
|
||||||
|
<>
|
||||||
|
<span>{model}</span>
|
||||||
|
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ChevronDown className="h-3 w-3" />
|
||||||
|
pick model
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState({
|
||||||
|
connState,
|
||||||
|
cwd,
|
||||||
|
}: {
|
||||||
|
connState: ConnectionState;
|
||||||
|
cwd: string | undefined;
|
||||||
|
}) {
|
||||||
|
const ready = connState === "open";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex items-center justify-center text-center px-4">
|
||||||
|
<div className="max-w-md space-y-4">
|
||||||
|
<div className="text-base text-foreground/80">
|
||||||
|
{ready ? (
|
||||||
|
<>
|
||||||
|
hermes is ready
|
||||||
|
<span className="ml-0.5 inline-block w-1.5 h-4 bg-foreground/60 align-middle animate-pulse" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"connecting to gateway…"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-muted-foreground/70 leading-relaxed">
|
||||||
|
same agent, same tools — served over a socket.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-center items-center gap-1.5 text-[0.7rem] text-muted-foreground/60 pt-1">
|
||||||
|
<span>type</span>
|
||||||
|
<kbd className="rounded border border-border bg-muted/40 px-1.5 py-0.5 font-mono">
|
||||||
|
/
|
||||||
|
</kbd>
|
||||||
|
<span>for slash commands,</span>
|
||||||
|
<kbd className="rounded border border-border bg-muted/40 px-1.5 py-0.5 font-mono">
|
||||||
|
Enter
|
||||||
|
</kbd>
|
||||||
|
<span>to send</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{cwd && (
|
||||||
|
<div className="pt-2 font-mono text-[0.65rem] text-muted-foreground/40 truncate">
|
||||||
|
cwd · {cwd}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageRow({ message }: { message: TextMessage }) {
|
||||||
|
if (message.role === "user") {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<div className="max-w-[80%] rounded-lg bg-primary text-primary-foreground px-3 py-2 whitespace-pre-wrap text-sm">
|
||||||
|
{message.text}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.role === "system") {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className="max-w-full rounded-md border border-dashed border-border bg-muted/20 px-3 py-1.5 text-xs text-muted-foreground font-mono whitespace-pre-wrap">
|
||||||
|
{message.text}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-start">
|
||||||
|
<div
|
||||||
|
className={`max-w-[85%] rounded-lg border px-3.5 py-2.5 ${
|
||||||
|
message.error
|
||||||
|
? "border-destructive/50 bg-destructive/5"
|
||||||
|
: "border-border bg-muted/30"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{message.text ? (
|
||||||
|
<Markdown content={message.text} streaming={message.streaming} />
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 text-muted-foreground text-sm italic">
|
||||||
|
thinking…
|
||||||
|
{message.streaming && (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="inline-block w-[0.5em] h-[1em] align-[-0.15em] bg-foreground/50 animate-pulse"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Hydration */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function hydrateMessages(list: HydratedMessage[]): ChatEntry[] {
|
||||||
|
return list.map(
|
||||||
|
(m, i): ChatEntry =>
|
||||||
|
m.role === "tool"
|
||||||
|
? {
|
||||||
|
kind: "tool",
|
||||||
|
id: `h-tool-${i}`,
|
||||||
|
tool_id: `h-tool-${i}`,
|
||||||
|
name: m.name ?? "tool",
|
||||||
|
context: m.context || undefined,
|
||||||
|
status: "done",
|
||||||
|
// Historical — no reliable timestamps in the hydrated payload.
|
||||||
|
startedAt: 0,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
kind: "message",
|
||||||
|
id: `h-msg-${i}`,
|
||||||
|
role: m.role,
|
||||||
|
text: m.text ?? "",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import { useEffect, useState, useCallback, useRef } from "react";
|
import { useEffect, useState, useCallback, useRef } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
|
Play,
|
||||||
Search,
|
Search,
|
||||||
Trash2,
|
Trash2,
|
||||||
Clock,
|
Clock,
|
||||||
|
|
@ -238,6 +240,7 @@ function SessionRow({
|
||||||
isExpanded,
|
isExpanded,
|
||||||
onToggle,
|
onToggle,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onOpen,
|
||||||
}: {
|
}: {
|
||||||
session: SessionInfo;
|
session: SessionInfo;
|
||||||
snippet?: string;
|
snippet?: string;
|
||||||
|
|
@ -245,6 +248,7 @@ function SessionRow({
|
||||||
isExpanded: boolean;
|
isExpanded: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
onOpen: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [messages, setMessages] = useState<SessionMessage[] | null>(null);
|
const [messages, setMessages] = useState<SessionMessage[] | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
@ -329,6 +333,19 @@ function SessionRow({
|
||||||
<Badge variant="outline" className="text-[10px]">
|
<Badge variant="outline" className="text-[10px]">
|
||||||
{session.source ?? "local"}
|
{session.source ?? "local"}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-muted-foreground hover:text-primary"
|
||||||
|
aria-label="Open in chat"
|
||||||
|
title="Open in chat"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onOpen();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Play className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|
@ -346,6 +363,12 @@ function SessionRow({
|
||||||
|
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="border-t border-border bg-background/50 p-4">
|
<div className="border-t border-border bg-background/50 p-4">
|
||||||
|
<div className="flex items-center justify-end pb-3">
|
||||||
|
<Button size="sm" variant="outline" onClick={onOpen}>
|
||||||
|
<Play className="h-3 w-3 mr-1.5" />
|
||||||
|
Open in chat
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-8">
|
||||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||||
|
|
@ -382,6 +405,14 @@ export default function SessionsPage() {
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleOpen = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
navigate(`/chat?resume=${encodeURIComponent(id)}`);
|
||||||
|
},
|
||||||
|
[navigate],
|
||||||
|
);
|
||||||
|
|
||||||
const loadSessions = useCallback((p: number) => {
|
const loadSessions = useCallback((p: number) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -517,6 +548,7 @@ export default function SessionsPage() {
|
||||||
setExpandedId((prev) => (prev === s.id ? null : s.id))
|
setExpandedId((prev) => (prev === s.id ? null : s.id))
|
||||||
}
|
}
|
||||||
onDelete={() => handleDelete(s.id)}
|
onDelete={() => handleDelete(s.id)}
|
||||||
|
onOpen={() => handleOpen(s.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,11 @@ export default defineConfig({
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": BACKEND,
|
// REST endpoints + the /api/ws WebSocket (ws: true enables upgrade forwarding).
|
||||||
|
"/api": {
|
||||||
|
target: BACKEND,
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue