mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
Wires the new /api/pty WebSocket into the dashboard as a top-level
Chat tab. Clicking Chat (or the ▶ play icon on any session row)
spawns a PTY running hermes --tui and renders its ANSI output with
xterm.js in the browser.
Frontend
--------
web/src/pages/ChatPage.tsx
* @xterm/xterm v6 + @xterm/addon-webgl renderer (pixel-perfect cell
grid — DOM and canvas renderers each have layout artifacts that
break box-drawing glyph connectivity in a browser)
* @xterm/addon-fit for container-driven resize
* @xterm/addon-unicode11 for modern wide-char widths (matches Ink's
string-width computation so kaomoji / CJK / emoji land on the
same cell boundaries as the host expects)
* @xterm/addon-web-links for URL auto-linking
* Rounded dark-teal "terminal window" container with 12px internal
padding + drop shadow for visual identity within the dashboard
* Clipboard wiring:
- Ctrl/Cmd+Shift+C copies xterm selection to system clipboard
- Ctrl/Cmd+Shift+V pastes system clipboard into the PTY
- OSC 52 handler writes terminal-emitted clipboard sequences
(how Ink's own Ctrl+C and /copy command deliver copy events);
decodes via TextDecoder so multi-byte UTF-8 codepoints
(U+2265, emoji, CJK) round-trip correctly
- Plain Ctrl+C still passes through as SIGINT to interrupt a
running response
* Floating "copy last response" button in the bottom-right corner.
Triggers Ink's /copy slash by sending bytes in two frames with a
100ms gap — Ink's tokenizer coalesces rapid adjacent bytes into
a paste event (bypasses the slash dispatcher), so we deliberately
split '/copy' and '\r' into separate packets to land them as
individual keypresses.
web/src/App.tsx
Chat nav entry (Terminal icon) at position 2 and <Route path="/chat">.
web/src/pages/SessionsPage.tsx
Play-icon button per session row that navigates to /chat?resume=<id>;
the PTY bridge forwards the resume param to hermes --tui --resume.
web/src/i18n/{en,zh,types}.ts
nav.chat label + sessions.resumeInChat action label.
web/vite.config.ts
/api proxy gains ws: true so WebSocket upgrades forward to :9119
when running Vite dev mode against a separate hermes dashboard
backend.
web/src/index.css + web/public/fonts-terminal/
Bundles JetBrains Mono (Regular/Bold/Italic, Apache-2.0, ~280 KB
total) as a local webfont. Fonts live outside web/public/fonts/
because the sync-assets prebuild step wipes that directory from
@nous-research/ui every build.
Package deps
------------
Net new: @xterm/xterm ^6.0.0, @xterm/addon-fit ^0.11.0,
@xterm/addon-webgl ^0.19.0, @xterm/addon-unicode11 ^0.9.0,
@xterm/addon-web-links ^0.12.0.
Bundle impact: +420 KB minified / +105 KB gzipped. Acceptable for a
feature that replaces what would otherwise be a rewrite of the entire
TUI surface in React.
Backend contract preserved
---------------------------
Every TUI affordance (slash popover, model picker, tool cards,
markdown streaming, clarify/sudo/approval prompts, skin engine, wide
chars, mouse tracking) lands in the browser unchanged because we are
running the real Ink binary. Adding a feature to the TUI surfaces in
the dashboard immediately. Do NOT add parallel React chat surfaces.
73 lines
2.1 KiB
TypeScript
73 lines
2.1 KiB
TypeScript
import { defineConfig, type Plugin } from "vite";
|
|
import react from "@vitejs/plugin-react";
|
|
import tailwindcss from "@tailwindcss/vite";
|
|
import path from "path";
|
|
|
|
const BACKEND = process.env.HERMES_DASHBOARD_URL ?? "http://127.0.0.1:9119";
|
|
|
|
/**
|
|
* In production the Python `hermes dashboard` server injects a one-shot
|
|
* session token into `index.html` (see `hermes_cli/web_server.py`). The
|
|
* Vite dev server serves its own `index.html`, so unless we forward that
|
|
* token, every protected `/api/*` call 401s.
|
|
*
|
|
* This plugin fetches the running dashboard's `index.html` on each dev page
|
|
* load, scrapes the `window.__HERMES_SESSION_TOKEN__` assignment, and
|
|
* re-injects it into the dev HTML. No-op in production builds.
|
|
*/
|
|
function hermesDevToken(): Plugin {
|
|
const TOKEN_RE = /window\.__HERMES_SESSION_TOKEN__\s*=\s*"([^"]+)"/;
|
|
|
|
return {
|
|
name: "hermes:dev-session-token",
|
|
apply: "serve",
|
|
async transformIndexHtml() {
|
|
try {
|
|
const res = await fetch(BACKEND, { headers: { accept: "text/html" } });
|
|
const html = await res.text();
|
|
const match = html.match(TOKEN_RE);
|
|
if (!match) {
|
|
console.warn(
|
|
`[hermes] Could not find session token in ${BACKEND} — ` +
|
|
`is \`hermes dashboard\` running? /api calls will 401.`,
|
|
);
|
|
return;
|
|
}
|
|
return [
|
|
{
|
|
tag: "script",
|
|
injectTo: "head",
|
|
children: `window.__HERMES_SESSION_TOKEN__="${match[1]}";`,
|
|
},
|
|
];
|
|
} catch (err) {
|
|
console.warn(
|
|
`[hermes] Dashboard at ${BACKEND} unreachable — ` +
|
|
`start it with \`hermes dashboard\` or set HERMES_DASHBOARD_URL. ` +
|
|
`(${(err as Error).message})`,
|
|
);
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
export default defineConfig({
|
|
plugins: [react(), tailwindcss(), hermesDevToken()],
|
|
resolve: {
|
|
alias: {
|
|
"@": path.resolve(__dirname, "./src"),
|
|
},
|
|
},
|
|
build: {
|
|
outDir: "../hermes_cli/web_dist",
|
|
emptyOutDir: true,
|
|
},
|
|
server: {
|
|
proxy: {
|
|
"/api": {
|
|
target: BACKEND,
|
|
ws: true,
|
|
},
|
|
},
|
|
},
|
|
});
|