feat(tui-gateway): WebSocket transport + /chat web UI, wire-compatible with Ink#12710
Closed
OutThisLife wants to merge 1 commit into
Closed
feat(tui-gateway): WebSocket transport + /chat web UI, wire-compatible with Ink#12710OutThisLife wants to merge 1 commit into
OutThisLife wants to merge 1 commit into
Conversation
Collaborator
Contributor
There was a problem hiding this comment.
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
Transportabstraction + transport-aware routing intui_gateway.server(stdio fallback preserved) and a new WS transport/handler. - Mount authenticated
/api/wson the dashboard and add a browser WS JSON-RPC client with a new/chatpage (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.
This was referenced Apr 23, 2026
…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).
9898efc to
25ba678
Compare
Merged
6 tasks
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)
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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/chatpage 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
Transportprotocol +contextvars.ContextVarbinding + a module-levelStdioTransport. The stdio stream is resolved through a callback so the ~100 existing tests thatmonkeypatch.setattr(server, "_real_stdout", ...)keep working without modification.tui_gateway/server.pywrite_jsonanddispatchare now transport-aware. Precedence for routing:dispatchfor the lifetime of a request, propagated to pool workers viacontextvars.copy_context()).tui_gateway/entry.pyis unchanged externally; Ink's stdio handshake behaves identically._sessions[sid]["transport"]is populated at every session-creation call site (session.create,_init_sessioncoveringsession.resumeandsession.branch).tui_gateway/ws.py(new)WSTransport+handle_wscoroutine.writeis 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 usesrun_coroutine_threadsafe(...).result().handle_wsawaits writes inline viawrite_asyncfor the frames it controls.hermes_cli/web_server.pyMounts
/api/wson the existing FastAPI app with the same ephemeral session-token gate used for REST. AddsHERMES_DASHBOARD_DEV_TOKENenv 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, typedon(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 tocommand.dispatch, resolve theexec/plugin/alias/skill/senddirectives. Mirrorsui-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 minimalhandleKeyref.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/modelin 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.resumehide the elapsed badge.web/src/App.tsxLogo wrapped in
<Link to="/">, Chat entry added to the nav.web/src/pages/SessionsPage.tsxEvery session row gets a play button, expanded panel gets an "Open in chat" button. Both navigate to
/chat?resume=<id>which triggerssession.resumeand hydrates the transcript before any live events.web/vite.config.ts/apiproxy configured withws: trueso WebSocket upgrades forward in dev mode.injectDevTokenplugin readsHERMES_DASHBOARD_DEV_TOKENand injects it into the servedindex.htmlmatching the production bootstrap, sowindow.__HERMES_SESSION_TOKEN__is present in both deployment modes.Tests
tests/hermes_cli/test_web_server.pygrows 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 throughserver.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/andtests/test_tui_gateway_server.pyall 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.py— 180 passedweb/:tsc -b --noEmitclean,eslintclean on all touched files,vite buildpasses (426 KB / 130 KB gzipped)python -m tui_gateway.entry:gateway.ready✓, parse error ✓, unknown method ✓, clean exit ✓hermes dashboard, open/chat, stream a response, trigger tool calls, use/helpand/model, open a session from/sessionsand resume itTry it
hermes dashboard # builds web, serves on :9119, click ChatDev with HMR:
Not in this PR (additive follow-ups)
GatewayClient.on(...)has typed hooks for each; just need the UI surfaces./chat(currently you go via/sessions).URLSessionWebSocketTask+ SwiftUI.