mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat(web): add Chat tab with xterm.js terminal + Sessions resume button
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.
This commit is contained in:
parent
29b337bca7
commit
3d21aee811
13 changed files with 590 additions and 1 deletions
82
web/package-lock.json
generated
82
web/package-lock.json
generated
|
|
@ -12,6 +12,11 @@
|
||||||
"@observablehq/plot": "^0.6.17",
|
"@observablehq/plot": "^0.6.17",
|
||||||
"@react-three/fiber": "^9.6.0",
|
"@react-three/fiber": "^9.6.0",
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/addon-unicode11": "^0.9.0",
|
||||||
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
|
"@xterm/addon-webgl": "^0.19.0",
|
||||||
|
"@xterm/xterm": "^6.0.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"gsap": "^3.15.0",
|
"gsap": "^3.15.0",
|
||||||
|
|
@ -39,6 +44,50 @@
|
||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"../../../../../wterm/packages/@wterm/core": {
|
||||||
|
"version": "0.1.9",
|
||||||
|
"extraneous": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@internal/ts": "workspace:*",
|
||||||
|
"typescript": "^6.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"../../../../../wterm/packages/@wterm/dom": {
|
||||||
|
"version": "0.1.9",
|
||||||
|
"extraneous": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@wterm/core": "workspace:*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@internal/ts": "workspace:*",
|
||||||
|
"jsdom": "^29.0.2",
|
||||||
|
"typescript": "^6.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"../../../../../wterm/packages/@wterm/react": {
|
||||||
|
"version": "0.1.9",
|
||||||
|
"extraneous": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@internal/ts": "workspace:*",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@wterm/dom": "workspace:*",
|
||||||
|
"jsdom": "^29.0.2",
|
||||||
|
"react": "^19.2.5",
|
||||||
|
"react-dom": "^19.2.5",
|
||||||
|
"typescript": "^6.0.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@wterm/dom": "workspace:*",
|
||||||
|
"react": "^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||||
|
|
@ -2861,6 +2910,39 @@
|
||||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
|
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@xterm/addon-fit": {
|
||||||
|
"version": "0.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
|
||||||
|
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@xterm/addon-unicode11": {
|
||||||
|
"version": "0.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0.tgz",
|
||||||
|
"integrity": "sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@xterm/addon-web-links": {
|
||||||
|
"version": "0.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
|
||||||
|
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@xterm/addon-webgl": {
|
||||||
|
"version": "0.19.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz",
|
||||||
|
"integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@xterm/xterm": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"addons/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.16.0",
|
"version": "8.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,11 @@
|
||||||
"@observablehq/plot": "^0.6.17",
|
"@observablehq/plot": "^0.6.17",
|
||||||
"@react-three/fiber": "^9.6.0",
|
"@react-three/fiber": "^9.6.0",
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/addon-unicode11": "^0.9.0",
|
||||||
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
|
"@xterm/addon-webgl": "^0.19.0",
|
||||||
|
"@xterm/xterm": "^6.0.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"gsap": "^3.15.0",
|
"gsap": "^3.15.0",
|
||||||
|
|
|
||||||
BIN
web/public/fonts-terminal/JetBrainsMono-Bold.woff2
Normal file
BIN
web/public/fonts-terminal/JetBrainsMono-Bold.woff2
Normal file
Binary file not shown.
BIN
web/public/fonts-terminal/JetBrainsMono-Italic.woff2
Normal file
BIN
web/public/fonts-terminal/JetBrainsMono-Italic.woff2
Normal file
Binary file not shown.
BIN
web/public/fonts-terminal/JetBrainsMono-Regular.woff2
Normal file
BIN
web/public/fonts-terminal/JetBrainsMono-Regular.woff2
Normal file
Binary file not shown.
|
|
@ -33,6 +33,7 @@ import LogsPage from "@/pages/LogsPage";
|
||||||
import AnalyticsPage from "@/pages/AnalyticsPage";
|
import AnalyticsPage from "@/pages/AnalyticsPage";
|
||||||
import CronPage from "@/pages/CronPage";
|
import CronPage from "@/pages/CronPage";
|
||||||
import SkillsPage from "@/pages/SkillsPage";
|
import SkillsPage from "@/pages/SkillsPage";
|
||||||
|
import ChatPage from "@/pages/ChatPage";
|
||||||
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
||||||
import { ThemeSwitcher } from "@/components/ThemeSwitcher";
|
import { ThemeSwitcher } from "@/components/ThemeSwitcher";
|
||||||
import { useI18n } from "@/i18n";
|
import { useI18n } from "@/i18n";
|
||||||
|
|
@ -41,6 +42,7 @@ import type { RegisteredPlugin } from "@/plugins";
|
||||||
|
|
||||||
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",
|
||||||
|
|
@ -230,6 +232,7 @@ export default function App() {
|
||||||
<main className="relative z-2 mx-auto w-full max-w-[1600px] flex-1 px-3 sm:px-6 pt-16 sm:pt-20 pb-4 sm:pb-8">
|
<main className="relative z-2 mx-auto w-full max-w-[1600px] flex-1 px-3 sm:px-6 pt-16 sm:pt-20 pb-4 sm:pb-8">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<StatusPage />} />
|
<Route path="/" element={<StatusPage />} />
|
||||||
|
<Route path="/chat" element={<ChatPage />} />
|
||||||
<Route path="/sessions" element={<SessionsPage />} />
|
<Route path="/sessions" element={<SessionsPage />} />
|
||||||
<Route path="/analytics" element={<AnalyticsPage />} />
|
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||||
<Route path="/logs" element={<LogsPage />} />
|
<Route path="/logs" element={<LogsPage />} />
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ export const en: Translations = {
|
||||||
},
|
},
|
||||||
nav: {
|
nav: {
|
||||||
status: "Status",
|
status: "Status",
|
||||||
|
chat: "Chat",
|
||||||
sessions: "Sessions",
|
sessions: "Sessions",
|
||||||
analytics: "Analytics",
|
analytics: "Analytics",
|
||||||
logs: "Logs",
|
logs: "Logs",
|
||||||
|
|
@ -97,6 +98,7 @@ export const en: Translations = {
|
||||||
noMessages: "No messages",
|
noMessages: "No messages",
|
||||||
untitledSession: "Untitled session",
|
untitledSession: "Untitled session",
|
||||||
deleteSession: "Delete session",
|
deleteSession: "Delete session",
|
||||||
|
resumeInChat: "Resume in Chat",
|
||||||
previousPage: "Previous page",
|
previousPage: "Previous page",
|
||||||
nextPage: "Next page",
|
nextPage: "Next page",
|
||||||
roles: {
|
roles: {
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ export interface Translations {
|
||||||
};
|
};
|
||||||
nav: {
|
nav: {
|
||||||
status: string;
|
status: string;
|
||||||
|
chat: string;
|
||||||
sessions: string;
|
sessions: string;
|
||||||
analytics: string;
|
analytics: string;
|
||||||
logs: string;
|
logs: string;
|
||||||
|
|
@ -101,6 +102,7 @@ export interface Translations {
|
||||||
noMessages: string;
|
noMessages: string;
|
||||||
untitledSession: string;
|
untitledSession: string;
|
||||||
deleteSession: string;
|
deleteSession: string;
|
||||||
|
resumeInChat: string;
|
||||||
previousPage: string;
|
previousPage: string;
|
||||||
nextPage: string;
|
nextPage: string;
|
||||||
roles: {
|
roles: {
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ export const zh: Translations = {
|
||||||
},
|
},
|
||||||
nav: {
|
nav: {
|
||||||
status: "状态",
|
status: "状态",
|
||||||
|
chat: "对话",
|
||||||
sessions: "会话",
|
sessions: "会话",
|
||||||
analytics: "分析",
|
analytics: "分析",
|
||||||
logs: "日志",
|
logs: "日志",
|
||||||
|
|
@ -97,6 +98,7 @@ export const zh: Translations = {
|
||||||
noMessages: "暂无消息",
|
noMessages: "暂无消息",
|
||||||
untitledSession: "无标题会话",
|
untitledSession: "无标题会话",
|
||||||
deleteSession: "删除会话",
|
deleteSession: "删除会话",
|
||||||
|
resumeInChat: "在对话中继续",
|
||||||
previousPage: "上一页",
|
previousPage: "上一页",
|
||||||
nextPage: "下一页",
|
nextPage: "下一页",
|
||||||
roles: {
|
roles: {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,36 @@
|
||||||
Tailwind's JIT purge. */
|
Tailwind's JIT purge. */
|
||||||
@source '../node_modules/@nous-research/ui/dist';
|
@source '../node_modules/@nous-research/ui/dist';
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* JetBrains Mono — bundled for the embedded TUI (/chat tab). */
|
||||||
|
/* Gives the terminal a proper monospace font even on systems where */
|
||||||
|
/* the user doesn't have one installed locally; xterm.js picks it up */
|
||||||
|
/* via ChatPage's `fontFamily` option. */
|
||||||
|
/* Apache-2.0. */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/fonts-terminal/JetBrainsMono-Regular.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/fonts-terminal/JetBrainsMono-Bold.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/fonts-terminal/JetBrainsMono-Italic.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Hermes Agent — Nous DS with the LENS_0 (Hermes teal) lens applied */
|
/* Hermes Agent — Nous DS with the LENS_0 (Hermes teal) lens applied */
|
||||||
/* statically. Mirrors nousnet-web/(hermes-agent)/layout.tsx so the */
|
/* statically. Mirrors nousnet-web/(hermes-agent)/layout.tsx so the */
|
||||||
|
|
|
||||||
444
web/src/pages/ChatPage.tsx
Normal file
444
web/src/pages/ChatPage.tsx
Normal file
|
|
@ -0,0 +1,444 @@
|
||||||
|
/**
|
||||||
|
* ChatPage — embeds `hermes --tui` inside the dashboard.
|
||||||
|
*
|
||||||
|
* <div host> (dashboard chrome) .
|
||||||
|
* └─ <div wrapper> (rounded, dark bg, padded — the "terminal window" .
|
||||||
|
* look that gives the page a distinct visual identity) .
|
||||||
|
* └─ @xterm/xterm Terminal (WebGL renderer, Unicode 11 widths) .
|
||||||
|
* │ onData keystrokes → WebSocket → PTY master .
|
||||||
|
* │ onResize terminal resize → `\x1b[RESIZE:cols;rows]` .
|
||||||
|
* │ write(data) PTY output bytes → VT100 parser .
|
||||||
|
* ▼ .
|
||||||
|
* WebSocket /api/pty?token=<session> .
|
||||||
|
* ▼ .
|
||||||
|
* FastAPI pty_ws (hermes_cli/web_server.py) .
|
||||||
|
* ▼ .
|
||||||
|
* POSIX PTY → `node ui-tui/dist/entry.js` → tui_gateway + AIAgent .
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
import { Terminal } from "@xterm/xterm";
|
||||||
|
import { FitAddon } from "@xterm/addon-fit";
|
||||||
|
import { WebglAddon } from "@xterm/addon-webgl";
|
||||||
|
import { Unicode11Addon } from "@xterm/addon-unicode11";
|
||||||
|
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||||
|
import { Copy } from "lucide-react";
|
||||||
|
import "@xterm/xterm/css/xterm.css";
|
||||||
|
|
||||||
|
function buildWsUrl(token: string, resume: string | null): string {
|
||||||
|
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
const qs = new URLSearchParams({ token });
|
||||||
|
if (resume) qs.set("resume", resume);
|
||||||
|
return `${proto}//${window.location.host}/api/pty?${qs.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Colors for the terminal body. Matches the dashboard's dark teal canvas
|
||||||
|
// with cream foreground — we intentionally don't pick monokai or a loud
|
||||||
|
// theme, because the TUI's skin engine already paints the content; the
|
||||||
|
// terminal chrome just needs to sit quietly inside the dashboard.
|
||||||
|
const TERMINAL_THEME = {
|
||||||
|
background: "#0d2626",
|
||||||
|
foreground: "#f0e6d2",
|
||||||
|
cursor: "#f0e6d2",
|
||||||
|
cursorAccent: "#0d2626",
|
||||||
|
selectionBackground: "#f0e6d244",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ChatPage() {
|
||||||
|
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const termRef = useRef<Terminal | null>(null);
|
||||||
|
const fitRef = useRef<FitAddon | null>(null);
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const [banner, setBanner] = useState<string | null>(null);
|
||||||
|
const [copyState, setCopyState] = useState<"idle" | "copied">("idle");
|
||||||
|
const copyResetRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const resumeRef = useRef<string | null>(searchParams.get("resume"));
|
||||||
|
|
||||||
|
const handleCopyLast = () => {
|
||||||
|
const ws = wsRef.current;
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
// Send the slash as a burst, wait long enough for Ink's tokenizer to
|
||||||
|
// emit a keypress event for each character (not coalesce them into a
|
||||||
|
// paste), then send Return as its own event. The timing here is
|
||||||
|
// empirical — 100ms is safely past Node's default stdin coalescing
|
||||||
|
// window and well inside UI responsiveness.
|
||||||
|
ws.send("/copy");
|
||||||
|
setTimeout(() => {
|
||||||
|
const s = wsRef.current;
|
||||||
|
if (s && s.readyState === WebSocket.OPEN) s.send("\r");
|
||||||
|
}, 100);
|
||||||
|
setCopyState("copied");
|
||||||
|
if (copyResetRef.current) clearTimeout(copyResetRef.current);
|
||||||
|
copyResetRef.current = setTimeout(() => setCopyState("idle"), 1500);
|
||||||
|
termRef.current?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const host = hostRef.current;
|
||||||
|
if (!host) return;
|
||||||
|
|
||||||
|
const token = window.__HERMES_SESSION_TOKEN__;
|
||||||
|
if (!token) {
|
||||||
|
setBanner(
|
||||||
|
"Session token unavailable. Open this page through `hermes dashboard`, not directly.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const term = new Terminal({
|
||||||
|
allowProposedApi: true,
|
||||||
|
cursorBlink: true,
|
||||||
|
fontFamily:
|
||||||
|
"'JetBrains Mono', 'Cascadia Mono', 'Fira Code', 'MesloLGS NF', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace",
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
macOptionIsMeta: true,
|
||||||
|
scrollback: 0,
|
||||||
|
theme: TERMINAL_THEME,
|
||||||
|
});
|
||||||
|
termRef.current = term;
|
||||||
|
|
||||||
|
// --- Clipboard integration ---------------------------------------
|
||||||
|
//
|
||||||
|
// Three independent paths all route to the system clipboard:
|
||||||
|
//
|
||||||
|
// 1. **Selection → Ctrl+C (or Cmd+C on macOS).** Ink's own handler
|
||||||
|
// in useInputHandlers.ts turns Ctrl+C into a copy when the
|
||||||
|
// terminal has a selection, then emits an OSC 52 escape. Our
|
||||||
|
// OSC 52 handler below decodes that escape and writes to the
|
||||||
|
// browser clipboard — so the flow works just like it does in
|
||||||
|
// `hermes --tui`.
|
||||||
|
//
|
||||||
|
// 2. **Ctrl/Cmd+Shift+C.** Belt-and-suspenders shortcut that
|
||||||
|
// operates directly on xterm's selection, useful if the TUI
|
||||||
|
// ever stops listening (e.g. overlays / pickers) or if the user
|
||||||
|
// has selected with the mouse outside of Ink's selection model.
|
||||||
|
//
|
||||||
|
// 3. **Ctrl/Cmd+Shift+V.** Reads the system clipboard and feeds
|
||||||
|
// it to the terminal as keyboard input. xterm's paste() wraps
|
||||||
|
// it with bracketed-paste if the host has that mode enabled.
|
||||||
|
//
|
||||||
|
// OSC 52 reads (terminal asking to read the clipboard) are not
|
||||||
|
// supported — that would let any content the TUI renders exfiltrate
|
||||||
|
// the user's clipboard.
|
||||||
|
term.parser.registerOscHandler(52, (data) => {
|
||||||
|
// Format: "<targets>;<base64 | '?'>"
|
||||||
|
const semi = data.indexOf(";");
|
||||||
|
if (semi < 0) return false;
|
||||||
|
const payload = data.slice(semi + 1);
|
||||||
|
if (payload === "?" || payload === "") return false; // read/clear — ignore
|
||||||
|
try {
|
||||||
|
// atob returns a binary string (one byte per char); we need UTF-8
|
||||||
|
// decode so multi-byte codepoints (≥, →, emoji, CJK) round-trip
|
||||||
|
// correctly. Without this step, the three UTF-8 bytes of `≥`
|
||||||
|
// would land in the clipboard as the three separate Latin-1
|
||||||
|
// characters `≥`.
|
||||||
|
const binary = atob(payload);
|
||||||
|
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
|
||||||
|
const text = new TextDecoder("utf-8").decode(bytes);
|
||||||
|
navigator.clipboard.writeText(text).catch(() => {});
|
||||||
|
} catch {
|
||||||
|
// Malformed base64 — silently drop.
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isMac =
|
||||||
|
typeof navigator !== "undefined" && /Mac/i.test(navigator.platform);
|
||||||
|
|
||||||
|
term.attachCustomKeyEventHandler((ev) => {
|
||||||
|
if (ev.type !== "keydown") return true;
|
||||||
|
|
||||||
|
const copyModifier = isMac ? ev.metaKey : ev.ctrlKey && ev.shiftKey;
|
||||||
|
const pasteModifier = isMac ? ev.metaKey : ev.ctrlKey && ev.shiftKey;
|
||||||
|
|
||||||
|
if (copyModifier && ev.key.toLowerCase() === "c") {
|
||||||
|
const sel = term.getSelection();
|
||||||
|
if (sel) {
|
||||||
|
navigator.clipboard.writeText(sel).catch(() => {});
|
||||||
|
ev.preventDefault();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pasteModifier && ev.key.toLowerCase() === "v") {
|
||||||
|
navigator.clipboard
|
||||||
|
.readText()
|
||||||
|
.then((text) => {
|
||||||
|
if (text) term.paste(text);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
ev.preventDefault();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const fit = new FitAddon();
|
||||||
|
fitRef.current = fit;
|
||||||
|
term.loadAddon(fit);
|
||||||
|
|
||||||
|
const unicode11 = new Unicode11Addon();
|
||||||
|
term.loadAddon(unicode11);
|
||||||
|
term.unicode.activeVersion = "11";
|
||||||
|
|
||||||
|
term.loadAddon(new WebLinksAddon());
|
||||||
|
|
||||||
|
term.open(host);
|
||||||
|
|
||||||
|
// WebGL renderer: rasterizes glyphs to a GPU texture atlas, paints
|
||||||
|
// each cell at an integer-pixel position. Box-drawing glyphs connect
|
||||||
|
// cleanly between rows (no DOM baseline / line-height math). Falls
|
||||||
|
// back to the default DOM renderer if WebGL is unavailable.
|
||||||
|
try {
|
||||||
|
const webgl = new WebglAddon();
|
||||||
|
webgl.onContextLoss(() => webgl.dispose());
|
||||||
|
term.loadAddon(webgl);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(
|
||||||
|
"[hermes-chat] WebGL renderer unavailable; falling back to default",
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial fit + resize observer. fit.fit() reads the container's
|
||||||
|
// current bounding box and resizes the terminal grid to match.
|
||||||
|
//
|
||||||
|
// The subtle bit: the dashboard has CSS transitions on the container
|
||||||
|
// (backdrop fade-in, rounded corners settling as fonts load). If we
|
||||||
|
// call fit() at mount time, the bounding box we measure is often 1-2
|
||||||
|
// cell widths off from the final size. ResizeObserver *does* fire
|
||||||
|
// when the container settles, but if the pixel delta happens to be
|
||||||
|
// smaller than one cell's width, fit() computes the same integer
|
||||||
|
// (cols, rows) as before and doesn't emit onResize — so the PTY
|
||||||
|
// never learns the final size. Users see truncated long lines until
|
||||||
|
// they resize the browser window.
|
||||||
|
//
|
||||||
|
// We force one extra fit + explicit RESIZE send after two animation
|
||||||
|
// frames. rAF→rAF guarantees one layout commit between the two
|
||||||
|
// callbacks, giving CSS transitions and font metrics time to finalize
|
||||||
|
// before we take the authoritative measurement.
|
||||||
|
let rafId = 0;
|
||||||
|
const scheduleFit = () => {
|
||||||
|
if (rafId) return;
|
||||||
|
rafId = requestAnimationFrame(() => {
|
||||||
|
rafId = 0;
|
||||||
|
try {
|
||||||
|
fit.fit();
|
||||||
|
} catch {
|
||||||
|
// Element was removed mid-resize; cleanup will handle it.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
fit.fit();
|
||||||
|
const ro = new ResizeObserver(scheduleFit);
|
||||||
|
ro.observe(host);
|
||||||
|
|
||||||
|
// Double-rAF authoritative fit. On the second frame the layout has
|
||||||
|
// committed at least once since mount; fit.fit() then reads the
|
||||||
|
// stable container size. We always send a RESIZE escape afterwards
|
||||||
|
// (even if fit's cols/rows didn't change, so the PTY has the same
|
||||||
|
// dims registered as our JS state — prevents a drift where Ink
|
||||||
|
// thinks the terminal is one col bigger than what's on screen).
|
||||||
|
let settleRaf1 = 0;
|
||||||
|
let settleRaf2 = 0;
|
||||||
|
settleRaf1 = requestAnimationFrame(() => {
|
||||||
|
settleRaf1 = 0;
|
||||||
|
settleRaf2 = requestAnimationFrame(() => {
|
||||||
|
settleRaf2 = 0;
|
||||||
|
try {
|
||||||
|
fit.fit();
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sock = wsRef.current;
|
||||||
|
if (sock && sock.readyState === WebSocket.OPEN) {
|
||||||
|
sock.send(`\x1b[RESIZE:${term.cols};${term.rows}]`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// WebSocket
|
||||||
|
const url = buildWsUrl(token, resumeRef.current);
|
||||||
|
const ws = new WebSocket(url);
|
||||||
|
ws.binaryType = "arraybuffer";
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
setBanner(null);
|
||||||
|
// Send the initial RESIZE immediately so Ink has *a* size to lay
|
||||||
|
// out against on its first paint. The double-rAF block above will
|
||||||
|
// follow up with the authoritative measurement — at worst Ink
|
||||||
|
// reflows once after the PTY boots, which is imperceptible.
|
||||||
|
ws.send(`\x1b[RESIZE:${term.cols};${term.rows}]`);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
if (typeof ev.data === "string") {
|
||||||
|
term.write(ev.data);
|
||||||
|
} else {
|
||||||
|
term.write(new Uint8Array(ev.data as ArrayBuffer));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = (ev) => {
|
||||||
|
wsRef.current = null;
|
||||||
|
if (ev.code === 4401) {
|
||||||
|
setBanner(
|
||||||
|
"Auth failed. Reload the page to refresh the session token.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ev.code === 4403) {
|
||||||
|
setBanner("Chat is only reachable from localhost.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ev.code === 1011) {
|
||||||
|
// Server already wrote an ANSI error frame.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
term.write("\r\n\x1b[90m[session ended]\x1b[0m\r\n");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keystrokes + mouse events → PTY, with cell-level dedup for motion.
|
||||||
|
//
|
||||||
|
// Ink enables `\x1b[?1003h` (any-motion tracking), which asks the
|
||||||
|
// terminal to report every mouse-move as an SGR mouse event even with
|
||||||
|
// no button held. xterm.js happily emits one report per pixel of
|
||||||
|
// mouse motion; without deduping, a casual mouse-over floods Ink with
|
||||||
|
// hundreds of redraw-triggering reports and the UI goes laggy
|
||||||
|
// (scrolling stutters, clicks land on stale positions by the time
|
||||||
|
// Ink finishes processing the motion backlog).
|
||||||
|
//
|
||||||
|
// We keep track of the last cell we reported a motion for. Press,
|
||||||
|
// release, and wheel events always pass through; motion events only
|
||||||
|
// pass through if the cell changed. Parsing is cheap — SGR reports
|
||||||
|
// are short literal strings.
|
||||||
|
const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/;
|
||||||
|
let lastMotionCell = { col: -1, row: -1 };
|
||||||
|
let lastMotionCb = -1;
|
||||||
|
const onDataDisposable = term.onData((data) => {
|
||||||
|
if (ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
|
||||||
|
const m = SGR_MOUSE_RE.exec(data);
|
||||||
|
if (m) {
|
||||||
|
const cb = parseInt(m[1], 10);
|
||||||
|
const col = parseInt(m[2], 10);
|
||||||
|
const row = parseInt(m[3], 10);
|
||||||
|
const released = m[4] === "m";
|
||||||
|
// Motion events have bit 0x20 (32) set in the button code.
|
||||||
|
// Wheel events have bit 0x40 (64); always forward wheel.
|
||||||
|
const isMotion = (cb & 0x20) !== 0 && (cb & 0x40) === 0;
|
||||||
|
const isWheel = (cb & 0x40) !== 0;
|
||||||
|
if (isMotion && !isWheel && !released) {
|
||||||
|
if (
|
||||||
|
col === lastMotionCell.col &&
|
||||||
|
row === lastMotionCell.row &&
|
||||||
|
cb === lastMotionCb
|
||||||
|
) {
|
||||||
|
return; // same cell + same button state; skip redundant report
|
||||||
|
}
|
||||||
|
lastMotionCell = { col, row };
|
||||||
|
lastMotionCb = cb;
|
||||||
|
} else {
|
||||||
|
// Non-motion event (press, release, wheel) — reset dedup state
|
||||||
|
// so the next motion after this always reports.
|
||||||
|
lastMotionCell = { col: -1, row: -1 };
|
||||||
|
lastMotionCb = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.send(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
const onResizeDisposable = term.onResize(({ cols, rows }) => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(`\x1b[RESIZE:${cols};${rows}]`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
term.focus();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
onDataDisposable.dispose();
|
||||||
|
onResizeDisposable.dispose();
|
||||||
|
ro.disconnect();
|
||||||
|
if (rafId) cancelAnimationFrame(rafId);
|
||||||
|
if (settleRaf1) cancelAnimationFrame(settleRaf1);
|
||||||
|
if (settleRaf2) cancelAnimationFrame(settleRaf2);
|
||||||
|
ws.close();
|
||||||
|
wsRef.current = null;
|
||||||
|
term.dispose();
|
||||||
|
termRef.current = null;
|
||||||
|
fitRef.current = null;
|
||||||
|
if (copyResetRef.current) {
|
||||||
|
clearTimeout(copyResetRef.current);
|
||||||
|
copyResetRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Layout:
|
||||||
|
// outer flex column — sits inside the dashboard's content area
|
||||||
|
// terminal wrapper — rounded, dark, padded — the "terminal window"
|
||||||
|
// floating copy button — bottom-right corner, transparent with a
|
||||||
|
// subtle border; stays out of the way until hovered. Sends
|
||||||
|
// `/copy\n` to Ink, which emits OSC 52 → our clipboard handler.
|
||||||
|
//
|
||||||
|
// `normal-case` opts out of the dashboard's global `uppercase` rule on
|
||||||
|
// the root `<div>` in App.tsx — terminal output must preserve case.
|
||||||
|
return (
|
||||||
|
<div className="flex h-[calc(100vh-10rem)] flex-col gap-2 normal-case">
|
||||||
|
{banner && (
|
||||||
|
<div className="border border-warning/50 bg-warning/10 text-warning px-3 py-2 text-xs tracking-wide">
|
||||||
|
{banner}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="relative flex-1 overflow-hidden rounded-lg"
|
||||||
|
style={{
|
||||||
|
backgroundColor: TERMINAL_THEME.background,
|
||||||
|
padding: "12px",
|
||||||
|
boxShadow: "0 8px 32px rgba(0, 0, 0, 0.4)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div ref={hostRef} className="h-full w-full" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopyLast}
|
||||||
|
title="Copy last assistant response as raw markdown"
|
||||||
|
aria-label="Copy last assistant response"
|
||||||
|
className={[
|
||||||
|
"absolute bottom-4 right-4 z-10",
|
||||||
|
"flex items-center gap-1.5",
|
||||||
|
"rounded border border-current/30",
|
||||||
|
"bg-black/20 backdrop-blur-sm",
|
||||||
|
"px-2.5 py-1.5 text-xs",
|
||||||
|
"opacity-60 hover:opacity-100 hover:border-current/60",
|
||||||
|
"transition-opacity duration-150",
|
||||||
|
"focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-current",
|
||||||
|
"cursor-pointer",
|
||||||
|
].join(" ")}
|
||||||
|
style={{ color: TERMINAL_THEME.foreground }}
|
||||||
|
>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
<span className="tracking-wide">
|
||||||
|
{copyState === "copied" ? "copied" : "copy last response"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__HERMES_SESSION_TOKEN__?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
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,
|
||||||
|
|
@ -12,6 +13,7 @@ import {
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
Hash,
|
Hash,
|
||||||
X,
|
X,
|
||||||
|
Play,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { H2 } from "@nous-research/ui";
|
import { H2 } from "@nous-research/ui";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
|
|
@ -250,6 +252,7 @@ function SessionRow({
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isExpanded && messages === null && !loading) {
|
if (isExpanded && messages === null && !loading) {
|
||||||
|
|
@ -329,6 +332,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-success"
|
||||||
|
aria-label={t.sessions.resumeInChat}
|
||||||
|
title={t.sessions.resumeInChat}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigate(`/chat?resume=${encodeURIComponent(session.id)}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Play className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,10 @@ export default defineConfig({
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": BACKEND,
|
"/api": {
|
||||||
|
target: BACKEND,
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue