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:
emozilla 2026-04-21 02:10:13 -04:00
parent 29b337bca7
commit 3d21aee811
13 changed files with 590 additions and 1 deletions

82
web/package-lock.json generated
View file

@ -12,6 +12,11 @@
"@observablehq/plot": "^0.6.17",
"@react-three/fiber": "^9.6.0",
"@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",
"clsx": "^2.1.1",
"gsap": "^3.15.0",
@ -39,6 +44,50 @@
"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": {
"version": "7.29.0",
"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"
}
},
"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": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",