Skip to content

feat(tui-gateway): WebSocket transport + /chat web UI, wire-compatible with Ink#12710

Closed
OutThisLife wants to merge 1 commit into
mainfrom
bb/tui-web-chat
Closed

feat(tui-gateway): WebSocket transport + /chat web UI, wire-compatible with Ink#12710
OutThisLife wants to merge 1 commit into
mainfrom
bb/tui-web-chat

Conversation

@OutThisLife

Copy link
Copy Markdown
Collaborator

Why

The Ink TUI drives tui_gateway's dispatcher over newline-delimited JSON-RPC on stdio. That same dispatcher is what any browser/iOS/Android client would want to talk to — the protocol is transport-agnostic, but the code wasn't. This PR makes it transport-agnostic in practice, mounts a WebSocket bridge on the existing FastAPI dashboard, and adds a /chat page to the dashboard that drives the full agent loop over the socket as a live proof.

Goal: one dispatcher, multiple frontends. Ink keeps working byte-identically; new clients speak the same dialect over WebSocket without duplicating a single handler.

Backend

tui_gateway/transport.py (new)
Minimal Transport protocol + contextvars.ContextVar binding + a module-level StdioTransport. The stdio stream is resolved through a callback so the ~100 existing tests that monkeypatch.setattr(server, "_real_stdout", ...) keep working without modification.

tui_gateway/server.py
write_json and dispatch are now transport-aware. Precedence for routing:

  1. Event frames with a session id → the session's bound transport (so async emits land on the right client even from pool worker threads).
  2. Otherwise the transport bound on the current contextvar (set by dispatch for the lifetime of a request, propagated to pool workers via contextvars.copy_context()).
  3. Otherwise the module-level stdio transport — the legacy path. tui_gateway/entry.py is unchanged externally; Ink's stdio handshake behaves identically.

_sessions[sid]["transport"] is populated at every session-creation call site (session.create, _init_session covering session.resume and session.branch).

tui_gateway/ws.py (new)
WSTransport + handle_ws coroutine. write is safe from any thread — it detects when it's called from the loop thread that owns the socket and fire-and-forget-schedules instead of deadlocking, otherwise uses run_coroutine_threadsafe(...).result(). handle_ws awaits writes inline via write_async for the frames it controls.

hermes_cli/web_server.py
Mounts /api/ws on the existing FastAPI app with the same ephemeral session-token gate used for REST. Adds HERMES_DASHBOARD_DEV_TOKEN env override so the Vite dev server (different port than the FastAPI backend) can share the token with the backend — no separate token-dispensing endpoint.

Frontend

web/src/lib/gatewayClient.ts (new)
Browser WebSocket JSON-RPC client, mirrors ui-tui/src/gatewayClient.ts. State listeners, typed on(type, cb) subscriptions, request/response map with timeout, reconnect on close.

web/src/lib/slashExec.ts (new)
Slash command pipeline: try slash.exec, fall back to command.dispatch, resolve the exec/plugin/alias/skill/send directives. Mirrors ui-tui/src/app/createSlashHandler.ts.

web/src/pages/ChatPage.tsx (new)
The chat view. Handles message.start/delta/complete, tool.start/progress/complete, error, session.info. Composer with Enter-to-send / Shift+Enter for newline. Interrupt button while busy. Reset session button. Copyable session id. Resume from ?resume=<id>.

web/src/components/SlashPopover.tsx (new)
Autocomplete popover above the composer — debounced complete.slash, arrow keys / Tab to apply, Esc to close. Parent owns key dispatch via a minimal handleKey ref.

web/src/components/ModelPickerDialog.tsx (new)
Two-stage provider/model picker modal, mirrors ui-tui/src/components/modelPicker.tsx. Search filter across providers + models, global-persist toggle. Confirms by emitting /model <m> --provider <slug> [--global] through the slash pipeline (same code path as typing /model in the composer).

web/src/components/ToolCall.tsx (new)
Expandable tool call row. Collapsed: chevron + name + context + status dot + elapsed. Expanded: context, streaming preview while running, colorized inline_diff, final summary or error. Errors auto-expand. Historical tools from session.resume hide the elapsed badge.

web/src/App.tsx
Logo wrapped in <Link to="/">, Chat entry added to the nav.

web/src/pages/SessionsPage.tsx
Every session row gets a play button, expanded panel gets an "Open in chat" button. Both navigate to /chat?resume=<id> which triggers session.resume and hydrates the transcript before any live events.

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 matching the production bootstrap, so window.__HERMES_SESSION_TOKEN__ is present in both deployment modes.

Tests

tests/hermes_cli/test_web_server.py grows three classes (15 new tests, all passing):

  • TestTuiGatewayWebSocket — handshake + gateway.ready, auth rejection (missing/bad token), parse errors, unknown methods, inline + pool handler round-trips, session event routing via the session-bound transport, WS disconnect resets the session's transport back to stdio so stray emits don't crash.
  • TestTuiGatewayTransportParity — same RPC over stdio vs WebSocket produces byte-identical response envelopes. Unknown method, inline handler, error envelope, explicit stdio transport — all compared as ===.
  • TestTuiGatewayE2EAnyPort — scripted multi-RPC conversation run identically through server.handle_request (direct) and through a live WS; ordered response sequence must match (modulo random session ids). This is the "Ink --tui in any port" check.

Existing tests under tests/tui_gateway/ and tests/test_tui_gateway_server.py all still pass unchanged. Backward compat is preserved because "no transport bound" falls back to the legacy stdio behavior.

Test plan

  • scripts/run_tests.sh tests/tui_gateway/ tests/test_tui_gateway_server.py tests/hermes_cli/test_web_server.py180 passed
  • web/: tsc -b --noEmit clean, eslint clean on all touched files, vite build passes (426 KB / 130 KB gzipped)
  • Live stdio smoke vs python -m tui_gateway.entry: gateway.ready ✓, parse error ✓, unknown method ✓, clean exit ✓
  • Manual: hermes dashboard, open /chat, stream a response, trigger tool calls, use /help and /model, open a session from /sessions and resume it

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           # terminal 1
cd web && npm run dev                # terminal 2, :5173

Not in this PR (additive follow-ups)

  • Approvals / clarify / sudo / secret prompt UIs — the events already fire and GatewayClient.on(...) has typed hooks for each; just need the UI surfaces.
  • Resume picker modal inside /chat (currently you go via /sessions).
  • File + image attachments.
  • iOS client — same WebSocket protocol; the JSON-RPC surface, token auth model, and event schema all translate directly to URLSessionWebSocketTask + SwiftUI.

@OutThisLife OutThisLife requested a review from Copilot April 21, 2026 21:40
@alt-glitch alt-glitch added type/feature New feature or request comp/tui Terminal UI (ui-tui/ + tui_gateway/) comp/gateway Gateway runner, session dispatch, delivery labels Apr 21, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

Related to #13379 (dashboard chat) — this PR provides the WebSocket transport layer that #13379 may build on.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Makes tui_gateway transport-agnostic by introducing a transport abstraction and adding a WebSocket bridge on the existing FastAPI dashboard, plus a new /chat web UI that drives the same newline-delimited JSON-RPC protocol as the Ink TUI.

Changes:

  • Add Transport abstraction + transport-aware routing in tui_gateway.server (stdio fallback preserved) and a new WS transport/handler.
  • Mount authenticated /api/ws on the dashboard and add a browser WS JSON-RPC client with a new /chat page (slash exec, streaming, tool calls, model picker, session resume).
  • Add dev-time token injection + WS proxying in Vite, and expand backend tests to cover WS parity and routing.

Reviewed changes

Copilot reviewed 15 out of 15 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
web/vite.config.ts Injects a pinned dev session token into index.html and enables WS proxying for /api.
web/src/pages/SessionsPage.tsx Adds “Open in chat” navigation to /chat?resume=<id> from session rows.
web/src/pages/ChatPage.tsx New chat UI driving the gateway over WebSocket (streaming, tools, interrupts, resume).
web/src/lib/slashExec.ts Implements slash-command execution pipeline mirroring the Ink TUI behavior.
web/src/lib/gatewayClient.ts WebSocket JSON-RPC client with typed event subscriptions and request/response handling.
web/src/components/ToolCall.tsx New expandable tool call transcript row with status, elapsed time, and diff rendering.
web/src/components/SlashPopover.tsx New debounced slash autocomplete popover with keyboard handling via an imperative ref.
web/src/components/ModelPickerDialog.tsx New provider/model picker modal that dispatches /model ... through the slash pipeline.
web/src/App.tsx Adds /chat route and nav entry; wraps logo with a home <Link>.
web/README.md Updates dev instructions for pinned token + WS proxying.
tui_gateway/ws.py New WS transport implementation and connection handler that reuses server.dispatch.
tui_gateway/transport.py New transport protocol, contextvar binding, and stdio transport wrapper.
tui_gateway/server.py Makes write_json/dispatch transport-aware and binds session transports at creation/init.
hermes_cli/web_server.py Adds /api/ws endpoint with token auth; supports HERMES_DASHBOARD_DEV_TOKEN.
tests/hermes_cli/test_web_server.py Adds WS handshake/auth/error tests + transport parity and E2E scripted runs.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread web/src/pages/ChatPage.tsx
Comment thread web/src/components/ModelPickerDialog.tsx
…e 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).
austinpickett added a commit that referenced this pull request Apr 24, 2026
feat(web): dashboard Chat tab — xterm.js + JSON-RPC sidecar (supersedes #12710 + #13379)
ulasbilgen pushed a commit to ulasbilgen/hermes-adhd-agent that referenced this pull request May 1, 2026
…at-unified

feat(web): dashboard Chat tab — xterm.js + JSON-RPC sidecar (supersedes NousResearch#12710 + NousResearch#13379)
aj-nt pushed a commit to aj-nt/hermes-agent that referenced this pull request May 1, 2026
…at-unified

feat(web): dashboard Chat tab — xterm.js + JSON-RPC sidecar (supersedes NousResearch#12710 + NousResearch#13379)
02356abc pushed a commit to 02356abc/hermes-agent that referenced this pull request May 14, 2026
…at-unified

feat(web): dashboard Chat tab — xterm.js + JSON-RPC sidecar (supersedes NousResearch#12710 + NousResearch#13379)
@teknium1 teknium1 mentioned this pull request May 24, 2026
11 tasks
gweeteve pushed a commit to gweeteve/hermes-agent that referenced this pull request Jun 2, 2026
…at-unified

feat(web): dashboard Chat tab — xterm.js + JSON-RPC sidecar (supersedes NousResearch#12710 + NousResearch#13379)
Egavasyug pushed a commit to Egavasyug/hermes-agent that referenced this pull request Jun 10, 2026
…at-unified

feat(web): dashboard Chat tab — xterm.js + JSON-RPC sidecar (supersedes NousResearch#12710 + NousResearch#13379)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery comp/tui Terminal UI (ui-tui/ + tui_gateway/) type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants