diff --git a/AGENTS.md b/AGENTS.md index ae78e005a..05a6742d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -240,6 +240,19 @@ npm run fmt # prettier npm test # vitest ``` +### TUI in the Dashboard (`hermes dashboard` → `/chat`) + +The dashboard embeds the real `hermes --tui` — **not** a rewrite. See `hermes_cli/pty_bridge.py` + the `@app.websocket("/api/pty")` endpoint in `hermes_cli/web_server.py`. + +- Browser loads `web/src/pages/ChatPage.tsx`, which mounts xterm.js's `Terminal` with the WebGL renderer, `@xterm/addon-fit` for container-driven resize, and `@xterm/addon-unicode11` for modern wide-character widths. +- `/api/pty?token=…` upgrades to a WebSocket; auth uses the same ephemeral `_SESSION_TOKEN` as REST, via query param (browsers can't set `Authorization` on WS upgrade). +- The server spawns whatever `hermes --tui` would spawn, through `ptyprocess` (POSIX PTY — WSL works, native Windows does not). +- Frames: raw PTY bytes each direction; resize via `\x1b[RESIZE:;]` intercepted on the server and applied with `TIOCSWINSZ`. + +**Do not re-implement the primary chat experience in React.** The main transcript, composer/input flow (including slash-command behavior), and PTY-backed terminal belong to the embedded `hermes --tui` — anything new you add to Ink shows up in the dashboard automatically. If you find yourself rebuilding the transcript or composer for the dashboard, stop and extend Ink instead. + +**Structured React UI around the TUI is allowed when it is not a second chat surface.** Sidebar widgets, inspectors, summaries, status panels, and similar supporting views (e.g. `ChatSidebar`, `ModelPickerDialog`, `ToolCall`) are fine when they complement the embedded TUI rather than replacing the transcript / composer / terminal. Keep their state independent of the PTY child's session and surface their failures non-destructively so the terminal pane keeps working unimpaired. + --- ## Adding New Tools diff --git a/hermes_cli/pty_bridge.py b/hermes_cli/pty_bridge.py new file mode 100644 index 000000000..b32013f7f --- /dev/null +++ b/hermes_cli/pty_bridge.py @@ -0,0 +1,221 @@ +"""PTY bridge for `hermes dashboard` chat tab. + +Wraps a child process behind a pseudo-terminal so its ANSI output can be +streamed to a browser-side terminal emulator (xterm.js) and typed +keystrokes can be fed back in. The only caller today is the +``/api/pty`` WebSocket endpoint in ``hermes_cli.web_server``. + +Design constraints: + +* **POSIX-only.** Hermes Agent supports Windows exclusively via WSL, which + exposes a native POSIX PTY via ``openpty(3)``. Native Windows Python + has no PTY; :class:`PtyUnavailableError` is raised with a user-readable + install/platform message so the dashboard can render a banner instead of + crashing. +* **Zero Node dependency on the server side.** We use :mod:`ptyprocess`, + which is a pure-Python wrapper around the OS calls. The browser talks + to the same ``hermes --tui`` binary it would launch from the CLI, so + every TUI feature (slash popover, model picker, tool rows, markdown, + skin engine, clarify/sudo/approval prompts) ships automatically. +* **Byte-safe I/O.** Reads and writes go through the PTY master fd + directly — we avoid :class:`ptyprocess.PtyProcessUnicode` because + streaming ANSI is inherently byte-oriented and UTF-8 boundaries may land + mid-read. +""" + +from __future__ import annotations + +import errno +import fcntl +import os +import select +import signal +import struct +import sys +import termios +import time +from typing import Optional, Sequence + +try: + import ptyprocess # type: ignore + _PTY_AVAILABLE = not sys.platform.startswith("win") +except ImportError: # pragma: no cover - dev env without ptyprocess + ptyprocess = None # type: ignore + _PTY_AVAILABLE = False + + +__all__ = ["PtyBridge", "PtyUnavailableError"] + + +class PtyUnavailableError(RuntimeError): + """Raised when a PTY cannot be created on this platform. + + Today this means native Windows (no ConPTY bindings) or a dev + environment missing the ``ptyprocess`` dependency. The dashboard + surfaces the message to the user as a chat-tab banner. + """ + + +class PtyBridge: + """Thin wrapper around ``ptyprocess.PtyProcess`` for byte streaming. + + Not thread-safe. A single bridge is owned by the WebSocket handler + that spawned it; the reader runs in an executor thread while writes + happen on the event-loop thread. Both sides are OK because the + kernel PTY is the actual synchronization point — we never call + :mod:`ptyprocess` methods concurrently, we only call ``os.read`` and + ``os.write`` on the master fd, which is safe. + """ + + def __init__(self, proc: "ptyprocess.PtyProcess"): # type: ignore[name-defined] + self._proc = proc + self._fd: int = proc.fd + self._closed = False + + # -- lifecycle -------------------------------------------------------- + + @classmethod + def is_available(cls) -> bool: + """True if a PTY can be spawned on this platform.""" + return bool(_PTY_AVAILABLE) + + @classmethod + def spawn( + cls, + argv: Sequence[str], + *, + cwd: Optional[str] = None, + env: Optional[dict] = None, + cols: int = 80, + rows: int = 24, + ) -> "PtyBridge": + """Spawn ``argv`` behind a new PTY and return a bridge. + + Raises :class:`PtyUnavailableError` if the platform can't host a + PTY. Raises :class:`FileNotFoundError` or :class:`OSError` for + ordinary exec failures (missing binary, bad cwd, etc.). + """ + if not _PTY_AVAILABLE: + raise PtyUnavailableError( + "Pseudo-terminals are unavailable on this platform. " + "Hermes Agent supports Windows only via WSL." + ) + # Let caller-supplied env fully override inheritance; if they pass + # None we inherit the server's env (same semantics as subprocess). + spawn_env = os.environ.copy() if env is None else env + proc = ptyprocess.PtyProcess.spawn( # type: ignore[union-attr] + list(argv), + cwd=cwd, + env=spawn_env, + dimensions=(rows, cols), + ) + return cls(proc) + + @property + def pid(self) -> int: + return int(self._proc.pid) + + def is_alive(self) -> bool: + if self._closed: + return False + try: + return bool(self._proc.isalive()) + except Exception: + return False + + # -- I/O -------------------------------------------------------------- + + def read(self, timeout: float = 0.2) -> Optional[bytes]: + """Read up to 64 KiB of raw bytes from the PTY master. + + Returns: + * bytes — zero or more bytes of child output + * empty bytes (``b""``) — no data available within ``timeout`` + * None — child has exited and the master fd is at EOF + + Never blocks longer than ``timeout`` seconds. Safe to call after + :meth:`close`; returns ``None`` in that case. + """ + if self._closed: + return None + try: + readable, _, _ = select.select([self._fd], [], [], timeout) + except (OSError, ValueError): + return None + if not readable: + return b"" + try: + data = os.read(self._fd, 65536) + except OSError as exc: + # EIO on Linux = slave side closed. EBADF = already closed. + if exc.errno in (errno.EIO, errno.EBADF): + return None + raise + if not data: + return None + return data + + def write(self, data: bytes) -> None: + """Write raw bytes to the PTY master (i.e. the child's stdin).""" + if self._closed or not data: + return + # os.write can return a short write under load; loop until drained. + view = memoryview(data) + while view: + try: + n = os.write(self._fd, view) + except OSError as exc: + if exc.errno in (errno.EIO, errno.EBADF, errno.EPIPE): + return + raise + if n <= 0: + return + view = view[n:] + + def resize(self, cols: int, rows: int) -> None: + """Forward a terminal resize to the child via ``TIOCSWINSZ``.""" + if self._closed: + return + # struct winsize: rows, cols, xpixel, ypixel (all unsigned short) + winsize = struct.pack("HHHH", max(1, rows), max(1, cols), 0, 0) + try: + fcntl.ioctl(self._fd, termios.TIOCSWINSZ, winsize) + except OSError: + pass + + # -- teardown --------------------------------------------------------- + + def close(self) -> None: + """Terminate the child (SIGTERM → 0.5s grace → SIGKILL) and close fds. + + Idempotent. Reaping the child is important so we don't leak + zombies across the lifetime of the dashboard process. + """ + if self._closed: + return + self._closed = True + + # SIGHUP is the conventional "your terminal went away" signal. + # We escalate if the child ignores it. + for sig in (signal.SIGHUP, signal.SIGTERM, signal.SIGKILL): + if not self._proc.isalive(): + break + try: + self._proc.kill(sig) + except Exception: + pass + deadline = time.monotonic() + 0.5 + while self._proc.isalive() and time.monotonic() < deadline: + time.sleep(0.02) + + try: + self._proc.close(force=True) + except Exception: + pass + + # Context-manager sugar — handy in tests and ad-hoc scripts. + def __enter__(self) -> "PtyBridge": + return self + + def __exit__(self, *_exc) -> None: + self.close() diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 083e0714f..e20bd6289 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -49,7 +49,7 @@ from hermes_cli.config import ( from gateway.status import get_running_pid, read_runtime_status try: - from fastapi import FastAPI, HTTPException, Request + from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, HTMLResponse, JSONResponse from fastapi.staticfiles import StaticFiles @@ -2263,6 +2263,313 @@ async def get_usage_analytics(days: int = 30): db.close() +# --------------------------------------------------------------------------- +# /api/pty — PTY-over-WebSocket bridge for the dashboard "Chat" tab. +# +# The endpoint spawns the same ``hermes --tui`` binary the CLI uses, behind +# a POSIX pseudo-terminal, and forwards bytes + resize escapes across a +# WebSocket. The browser renders the ANSI through xterm.js (see +# web/src/pages/ChatPage.tsx). +# +# Auth: ``?token=`` query param (browsers can't set +# Authorization on the WS upgrade). Same ephemeral ``_SESSION_TOKEN`` as +# REST. Localhost-only — we defensively reject non-loopback clients even +# though uvicorn binds to 127.0.0.1. +# --------------------------------------------------------------------------- + +import re +import asyncio + +from hermes_cli.pty_bridge import PtyBridge, PtyUnavailableError + +_RESIZE_RE = re.compile(rb"\x1b\[RESIZE:(\d+);(\d+)\]") +_PTY_READ_CHUNK_TIMEOUT = 0.2 +_VALID_CHANNEL_RE = re.compile(r"^[A-Za-z0-9._-]{1,128}$") +# Starlette's TestClient reports the peer as "testclient"; treat it as +# loopback so tests don't need to rewrite request scope. +_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost", "testclient"}) + +# Per-channel subscriber registry used by /api/pub (PTY-side gateway → dashboard) +# and /api/events (dashboard → browser sidebar). Keyed by an opaque channel id +# the chat tab generates on mount; entries auto-evict when the last subscriber +# drops AND the publisher has disconnected. +_event_channels: dict[str, set] = {} +_event_lock = asyncio.Lock() + + +def _resolve_chat_argv( + resume: Optional[str] = None, + sidecar_url: Optional[str] = None, +) -> tuple[list[str], Optional[str], Optional[dict]]: + """Resolve the argv + cwd + env for the chat PTY. + + Default: whatever ``hermes --tui`` would run. Tests monkeypatch this + function to inject a tiny fake command (``cat``, ``sh -c 'printf …'``) + so nothing has to build Node or the TUI bundle. + + Session resume is propagated via the ``HERMES_TUI_RESUME`` env var — + matching what ``hermes_cli.main._launch_tui`` does for the CLI path. + Appending ``--resume `` to argv doesn't work because ``ui-tui`` does + not parse its argv. + + `sidecar_url` (when set) is forwarded as ``HERMES_TUI_SIDECAR_URL`` so + the spawned ``tui_gateway.entry`` can mirror dispatcher emits to the + dashboard's ``/api/pub`` endpoint (see :func:`pub_ws`). + """ + from hermes_cli.main import PROJECT_ROOT, _make_tui_argv + + argv, cwd = _make_tui_argv(PROJECT_ROOT / "ui-tui", tui_dev=False) + env: Optional[dict] = None + + if resume or sidecar_url: + env = os.environ.copy() + + if resume: + env["HERMES_TUI_RESUME"] = resume + + if sidecar_url: + env["HERMES_TUI_SIDECAR_URL"] = sidecar_url + + return list(argv), str(cwd) if cwd else None, env + + +def _build_sidecar_url(channel: str) -> Optional[str]: + """ws:// URL the PTY child should publish events to, or None when unbound.""" + host = getattr(app.state, "bound_host", None) + port = getattr(app.state, "bound_port", None) + + if not host or not port: + return None + + netloc = f"[{host}]:{port}" if ":" in host and not host.startswith("[") else f"{host}:{port}" + qs = urllib.parse.urlencode({"token": _SESSION_TOKEN, "channel": channel}) + + return f"ws://{netloc}/api/pub?{qs}" + + +async def _broadcast_event(channel: str, payload: str) -> None: + """Fan out one publisher frame to every subscriber on `channel`.""" + async with _event_lock: + subs = list(_event_channels.get(channel, ())) + + for sub in subs: + try: + await sub.send_text(payload) + except Exception: + # Subscriber went away mid-send; the /api/events finally clause + # will remove it from the registry on its next iteration. + pass + + +def _channel_or_close_code(ws: WebSocket) -> Optional[str]: + """Return the channel id from the query string or None if invalid.""" + channel = ws.query_params.get("channel", "") + + return channel if _VALID_CHANNEL_RE.match(channel) else None + + +@app.websocket("/api/pty") +async def pty_ws(ws: WebSocket) -> None: + # --- auth + loopback check (before accept so we can close cleanly) --- + token = ws.query_params.get("token", "") + expected = _SESSION_TOKEN + if not hmac.compare_digest(token.encode(), expected.encode()): + await ws.close(code=4401) + return + + client_host = ws.client.host if ws.client else "" + if client_host and client_host not in _LOOPBACK_HOSTS: + await ws.close(code=4403) + return + + await ws.accept() + + # --- spawn PTY ------------------------------------------------------ + resume = ws.query_params.get("resume") or None + channel = _channel_or_close_code(ws) + sidecar_url = _build_sidecar_url(channel) if channel else None + + try: + argv, cwd, env = _resolve_chat_argv(resume=resume, sidecar_url=sidecar_url) + except SystemExit as exc: + # _make_tui_argv calls sys.exit(1) when node/npm is missing. + await ws.send_text(f"\r\n\x1b[31mChat unavailable: {exc}\x1b[0m\r\n") + await ws.close(code=1011) + return + + + try: + bridge = PtyBridge.spawn(argv, cwd=cwd, env=env) + except PtyUnavailableError as exc: + await ws.send_text(f"\r\n\x1b[31mChat unavailable: {exc}\x1b[0m\r\n") + await ws.close(code=1011) + return + except (FileNotFoundError, OSError) as exc: + await ws.send_text(f"\r\n\x1b[31mChat failed to start: {exc}\x1b[0m\r\n") + await ws.close(code=1011) + return + + loop = asyncio.get_running_loop() + + # --- reader task: PTY master → WebSocket ---------------------------- + async def pump_pty_to_ws() -> None: + while True: + chunk = await loop.run_in_executor( + None, bridge.read, _PTY_READ_CHUNK_TIMEOUT + ) + if chunk is None: # EOF + return + if not chunk: # no data this tick; yield control and retry + await asyncio.sleep(0) + continue + try: + await ws.send_bytes(chunk) + except Exception: + return + + reader_task = asyncio.create_task(pump_pty_to_ws()) + + # --- writer loop: WebSocket → PTY master ---------------------------- + try: + while True: + msg = await ws.receive() + msg_type = msg.get("type") + if msg_type == "websocket.disconnect": + break + raw = msg.get("bytes") + if raw is None: + text = msg.get("text") + raw = text.encode("utf-8") if isinstance(text, str) else b"" + if not raw: + continue + + # Resize escape is consumed locally, never written to the PTY. + match = _RESIZE_RE.match(raw) + if match and match.end() == len(raw): + cols = int(match.group(1)) + rows = int(match.group(2)) + bridge.resize(cols=cols, rows=rows) + continue + + bridge.write(raw) + except WebSocketDisconnect: + pass + finally: + reader_task.cancel() + try: + await reader_task + except (asyncio.CancelledError, Exception): + pass + bridge.close() + + +# --------------------------------------------------------------------------- +# /api/ws — JSON-RPC WebSocket sidecar for the dashboard "Chat" tab. +# +# Drives the same `tui_gateway.dispatch` surface Ink uses over stdio, so the +# dashboard can render structured metadata (model badge, tool-call sidebar, +# slash launcher, session info) alongside the xterm.js terminal that PTY +# already paints. Both transports bind to the same session id when one is +# active, so a tool.start emitted by the agent fans out to both sinks. +# --------------------------------------------------------------------------- + + +@app.websocket("/api/ws") +async def gateway_ws(ws: WebSocket) -> None: + token = ws.query_params.get("token", "") + if not hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode()): + await ws.close(code=4401) + return + + client_host = ws.client.host if ws.client else "" + if client_host and client_host not in _LOOPBACK_HOSTS: + await ws.close(code=4403) + return + + from tui_gateway.ws import handle_ws + + await handle_ws(ws) + + +# --------------------------------------------------------------------------- +# /api/pub + /api/events — chat-tab event broadcast. +# +# The PTY-side ``tui_gateway.entry`` opens /api/pub at startup (driven by +# HERMES_TUI_SIDECAR_URL set in /api/pty's PTY env) and writes every +# dispatcher emit through it. The dashboard fans those frames out to any +# subscriber that opened /api/events on the same channel id. This is what +# gives the React sidebar its tool-call feed without breaking the PTY +# child's stdio handshake with Ink. +# --------------------------------------------------------------------------- + + +@app.websocket("/api/pub") +async def pub_ws(ws: WebSocket) -> None: + token = ws.query_params.get("token", "") + if not hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode()): + await ws.close(code=4401) + return + + client_host = ws.client.host if ws.client else "" + if client_host and client_host not in _LOOPBACK_HOSTS: + await ws.close(code=4403) + return + + channel = _channel_or_close_code(ws) + if not channel: + await ws.close(code=4400) + return + + await ws.accept() + + try: + while True: + await _broadcast_event(channel, await ws.receive_text()) + except WebSocketDisconnect: + pass + + +@app.websocket("/api/events") +async def events_ws(ws: WebSocket) -> None: + token = ws.query_params.get("token", "") + if not hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode()): + await ws.close(code=4401) + return + + client_host = ws.client.host if ws.client else "" + if client_host and client_host not in _LOOPBACK_HOSTS: + await ws.close(code=4403) + return + + channel = _channel_or_close_code(ws) + if not channel: + await ws.close(code=4400) + return + + await ws.accept() + + async with _event_lock: + _event_channels.setdefault(channel, set()).add(ws) + + try: + while True: + # Subscribers don't speak — the receive() just blocks until + # disconnect so the connection stays open as long as the + # browser holds it. + await ws.receive_text() + except WebSocketDisconnect: + pass + finally: + async with _event_lock: + subs = _event_channels.get(channel) + + if subs is not None: + subs.discard(ws) + + if not subs: + _event_channels.pop(channel, None) + + def mount_spa(application: FastAPI): """Mount the built SPA. Falls back to index.html for client-side routing. @@ -2817,7 +3124,10 @@ def start_server( # Record the bound host so host_header_middleware can validate incoming # Host headers against it. Defends against DNS rebinding (GHSA-ppp5-vxwm-4cf7). + # bound_port is also stashed so /api/pty can build the back-WS URL the + # PTY child uses to publish events to the dashboard sidebar. app.state.bound_host = host + app.state.bound_port = port if open_browser: import webbrowser diff --git a/tests/hermes_cli/test_pty_bridge.py b/tests/hermes_cli/test_pty_bridge.py new file mode 100644 index 000000000..cd6983b90 --- /dev/null +++ b/tests/hermes_cli/test_pty_bridge.py @@ -0,0 +1,172 @@ +"""Unit tests for hermes_cli.pty_bridge — PTY spawning + byte forwarding. + +These tests drive the bridge with minimal POSIX processes (echo, env, sleep, +printf) to verify it behaves like a PTY you can read/write/resize/close. +""" + +from __future__ import annotations + +import os +import sys +import time + +import pytest + +pytest.importorskip("ptyprocess", reason="ptyprocess not installed") + +from hermes_cli.pty_bridge import PtyBridge, PtyUnavailableError + + +skip_on_windows = pytest.mark.skipif( + sys.platform.startswith("win"), reason="PTY bridge is POSIX-only" +) + + +def _read_until(bridge: PtyBridge, needle: bytes, timeout: float = 5.0) -> bytes: + """Accumulate PTY output until we see `needle` or time out.""" + deadline = time.monotonic() + timeout + buf = bytearray() + while time.monotonic() < deadline: + chunk = bridge.read(timeout=0.2) + if chunk is None: + break + buf.extend(chunk) + if needle in buf: + return bytes(buf) + return bytes(buf) + + +@skip_on_windows +class TestPtyBridgeSpawn: + def test_is_available_on_posix(self): + assert PtyBridge.is_available() is True + + def test_spawn_returns_bridge_with_pid(self): + bridge = PtyBridge.spawn(["true"]) + try: + assert bridge.pid > 0 + finally: + bridge.close() + + def test_spawn_raises_on_missing_argv0(self, tmp_path): + with pytest.raises((FileNotFoundError, OSError)): + PtyBridge.spawn([str(tmp_path / "definitely-not-a-real-binary")]) + + +@skip_on_windows +class TestPtyBridgeIO: + def test_reads_child_stdout(self): + bridge = PtyBridge.spawn(["/bin/sh", "-c", "printf hermes-ok"]) + try: + output = _read_until(bridge, b"hermes-ok") + assert b"hermes-ok" in output + finally: + bridge.close() + + def test_write_sends_to_child_stdin(self): + # `cat` with no args echoes stdin back to stdout. We write a line, + # read it back, then signal EOF to let cat exit cleanly. + bridge = PtyBridge.spawn(["/bin/cat"]) + try: + bridge.write(b"hello-pty\n") + output = _read_until(bridge, b"hello-pty") + assert b"hello-pty" in output + finally: + bridge.close() + + def test_read_returns_none_after_child_exits(self): + bridge = PtyBridge.spawn(["/bin/sh", "-c", "printf done"]) + try: + _read_until(bridge, b"done") + # Give the child a beat to exit cleanly, then drain until EOF. + deadline = time.monotonic() + 3.0 + while bridge.is_alive() and time.monotonic() < deadline: + bridge.read(timeout=0.1) + # Next reads after exit should return None (EOF), not raise. + got_none = False + for _ in range(10): + if bridge.read(timeout=0.1) is None: + got_none = True + break + assert got_none, "PtyBridge.read did not return None after child EOF" + finally: + bridge.close() + + +@skip_on_windows +class TestPtyBridgeResize: + def test_resize_updates_child_winsize(self): + # tput reads COLUMNS/LINES from the TTY ioctl (TIOCGWINSZ). + # Spawn a shell, resize, then ask tput for the dimensions. + bridge = PtyBridge.spawn( + ["/bin/sh", "-c", "sleep 0.1; tput cols; tput lines"], + cols=80, + rows=24, + ) + try: + bridge.resize(cols=123, rows=45) + output = _read_until(bridge, b"45", timeout=5.0) + # tput prints just the numbers, one per line + assert b"123" in output + assert b"45" in output + finally: + bridge.close() + + +@skip_on_windows +class TestPtyBridgeClose: + def test_close_is_idempotent(self): + bridge = PtyBridge.spawn(["/bin/sh", "-c", "sleep 30"]) + bridge.close() + bridge.close() # must not raise + assert not bridge.is_alive() + + def test_close_terminates_long_running_child(self): + bridge = PtyBridge.spawn(["/bin/sh", "-c", "sleep 30"]) + pid = bridge.pid + bridge.close() + # Give the kernel a moment to reap + deadline = time.monotonic() + 3.0 + reaped = False + while time.monotonic() < deadline: + try: + os.kill(pid, 0) + time.sleep(0.05) + except ProcessLookupError: + reaped = True + break + assert reaped, f"pid {pid} still running after close()" + + +@skip_on_windows +class TestPtyBridgeEnv: + def test_cwd_is_respected(self, tmp_path): + bridge = PtyBridge.spawn( + ["/bin/sh", "-c", "pwd"], + cwd=str(tmp_path), + ) + try: + output = _read_until(bridge, str(tmp_path).encode()) + assert str(tmp_path).encode() in output + finally: + bridge.close() + + def test_env_is_forwarded(self): + bridge = PtyBridge.spawn( + ["/bin/sh", "-c", "printf %s \"$HERMES_PTY_TEST\""], + env={**os.environ, "HERMES_PTY_TEST": "pty-env-works"}, + ) + try: + output = _read_until(bridge, b"pty-env-works") + assert b"pty-env-works" in output + finally: + bridge.close() + + +class TestPtyBridgeUnavailable: + """Platform fallback semantics — PtyUnavailableError is importable and + carries a user-readable message.""" + + def test_error_carries_user_message(self): + err = PtyUnavailableError("platform not supported") + assert "platform" in str(err) diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index a92f0c8d1..8ff9285e4 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -1677,3 +1677,241 @@ class TestDashboardPluginManifestExtensions: plugins = web_server._get_dashboard_plugins(force_rescan=True) entry = next(p for p in plugins if p["name"] == "mixed-slots") assert entry["slots"] == ["sidebar", "header-right"] + + +# --------------------------------------------------------------------------- +# /api/pty WebSocket — terminal bridge for the dashboard "Chat" tab. +# +# These tests drive the endpoint with a tiny fake command (typically ``cat`` +# or ``sh -c 'printf …'``) instead of the real ``hermes --tui`` binary. The +# endpoint resolves its argv through ``_resolve_chat_argv``, so tests +# monkeypatch that hook. +# --------------------------------------------------------------------------- + +import sys + + +skip_on_windows = pytest.mark.skipif( + sys.platform.startswith("win"), reason="PTY bridge is POSIX-only" +) + + +@skip_on_windows +class TestPtyWebSocket: + @pytest.fixture(autouse=True) + def _setup(self, monkeypatch, _isolate_hermes_home): + from starlette.testclient import TestClient + + import hermes_cli.web_server as ws + + # Avoid exec'ing the actual TUI in tests: every test below installs + # its own fake argv via ``ws._resolve_chat_argv``. + self.ws_module = ws + self.token = ws._SESSION_TOKEN + self.client = TestClient(ws.app) + + def _url(self, token: str | None = None, **params: str) -> str: + tok = token if token is not None else self.token + # TestClient.websocket_connect takes the path; it reconstructs the + # query string, so we pass it inline. + from urllib.parse import urlencode + + q = {"token": tok, **params} + return f"/api/pty?{urlencode(q)}" + + def test_rejects_missing_token(self, monkeypatch): + monkeypatch.setattr( + self.ws_module, + "_resolve_chat_argv", + lambda resume=None, sidecar_url=None: (["/bin/cat"], None, None), + ) + from starlette.websockets import WebSocketDisconnect + + with pytest.raises(WebSocketDisconnect) as exc: + with self.client.websocket_connect("/api/pty"): + pass + assert exc.value.code == 4401 + + def test_rejects_bad_token(self, monkeypatch): + monkeypatch.setattr( + self.ws_module, + "_resolve_chat_argv", + lambda resume=None, sidecar_url=None: (["/bin/cat"], None, None), + ) + from starlette.websockets import WebSocketDisconnect + + with pytest.raises(WebSocketDisconnect) as exc: + with self.client.websocket_connect(self._url(token="wrong")): + pass + assert exc.value.code == 4401 + + def test_streams_child_stdout_to_client(self, monkeypatch): + monkeypatch.setattr( + self.ws_module, + "_resolve_chat_argv", + lambda resume=None, sidecar_url=None: ( + ["/bin/sh", "-c", "printf hermes-ws-ok"], + None, + None, + ), + ) + with self.client.websocket_connect(self._url()) as conn: + # Drain frames until we see the needle or time out. TestClient's + # recv_bytes blocks; loop until we have the signal byte string. + buf = b"" + import time + + deadline = time.monotonic() + 5.0 + while time.monotonic() < deadline: + try: + frame = conn.receive_bytes() + except Exception: + break + if frame: + buf += frame + if b"hermes-ws-ok" in buf: + break + assert b"hermes-ws-ok" in buf + + def test_client_input_reaches_child_stdin(self, monkeypatch): + # ``cat`` echoes stdin back, so a write → read round-trip proves + # the full duplex path. + monkeypatch.setattr( + self.ws_module, + "_resolve_chat_argv", + lambda resume=None, sidecar_url=None: (["/bin/cat"], None, None), + ) + with self.client.websocket_connect(self._url()) as conn: + conn.send_bytes(b"round-trip-payload\n") + buf = b"" + import time + + deadline = time.monotonic() + 5.0 + while time.monotonic() < deadline: + frame = conn.receive_bytes() + if frame: + buf += frame + if b"round-trip-payload" in buf: + break + assert b"round-trip-payload" in buf + + def test_resize_escape_is_forwarded(self, monkeypatch): + # Resize escape gets intercepted and applied via TIOCSWINSZ, + # then ``tput cols/lines`` reports the new dimensions back. + monkeypatch.setattr( + self.ws_module, + "_resolve_chat_argv", + # sleep gives the test time to push the resize before tput runs + lambda resume=None, sidecar_url=None: ( + ["/bin/sh", "-c", "sleep 0.15; tput cols; tput lines"], + None, + None, + ), + ) + with self.client.websocket_connect(self._url()) as conn: + conn.send_text("\x1b[RESIZE:99;41]") + buf = b"" + import time + + deadline = time.monotonic() + 5.0 + while time.monotonic() < deadline: + frame = conn.receive_bytes() + if frame: + buf += frame + if b"99" in buf and b"41" in buf: + break + assert b"99" in buf and b"41" in buf + + def test_unavailable_platform_closes_with_message(self, monkeypatch): + from hermes_cli.pty_bridge import PtyUnavailableError + + def _raise(argv, **kwargs): + raise PtyUnavailableError("pty missing for tests") + + monkeypatch.setattr( + self.ws_module, + "_resolve_chat_argv", + lambda resume=None, sidecar_url=None: (["/bin/cat"], None, None), + ) + # Patch PtyBridge.spawn at the web_server module's binding. + import hermes_cli.web_server as ws_mod + + monkeypatch.setattr(ws_mod.PtyBridge, "spawn", classmethod(lambda cls, *a, **k: _raise(*a, **k))) + + with self.client.websocket_connect(self._url()) as conn: + # Expect a final text frame with the error message, then close. + msg = conn.receive_text() + assert "pty missing" in msg or "unavailable" in msg.lower() or "pty" in msg.lower() + + def test_resume_parameter_is_forwarded_to_argv(self, monkeypatch): + captured: dict = {} + + def fake_resolve(resume=None, sidecar_url=None): + captured["resume"] = resume + return (["/bin/sh", "-c", "printf resume-arg-ok"], None, None) + + monkeypatch.setattr(self.ws_module, "_resolve_chat_argv", fake_resolve) + + with self.client.websocket_connect(self._url(resume="sess-42")) as conn: + # Drain briefly so the handler actually invokes the resolver. + try: + conn.receive_bytes() + except Exception: + pass + assert captured.get("resume") == "sess-42" + + def test_channel_param_propagates_sidecar_url(self, monkeypatch): + """When /api/pty is opened with ?channel=, the PTY child gets a + HERMES_TUI_SIDECAR_URL env var pointing back at /api/pub on the + same channel — which is how tool events reach the dashboard sidebar.""" + captured: dict = {} + + def fake_resolve(resume=None, sidecar_url=None): + captured["sidecar_url"] = sidecar_url + return (["/bin/sh", "-c", "printf sidecar-ok"], None, None) + + monkeypatch.setattr(self.ws_module, "_resolve_chat_argv", fake_resolve) + monkeypatch.setattr( + self.ws_module.app.state, "bound_host", "127.0.0.1", raising=False + ) + monkeypatch.setattr( + self.ws_module.app.state, "bound_port", 9119, raising=False + ) + + with self.client.websocket_connect(self._url(channel="abc-123")) as conn: + try: + conn.receive_bytes() + except Exception: + pass + + url = captured.get("sidecar_url") or "" + assert url.startswith("ws://127.0.0.1:9119/api/pub?") + assert "channel=abc-123" in url + assert "token=" in url + + def test_pub_broadcasts_to_events_subscribers(self, monkeypatch): + """Frame written to /api/pub is rebroadcast verbatim to every + /api/events subscriber on the same channel.""" + from urllib.parse import urlencode + + qs = urlencode({"token": self.token, "channel": "broadcast-test"}) + pub_path = f"/api/pub?{qs}" + sub_path = f"/api/events?{qs}" + + with self.client.websocket_connect(sub_path) as sub: + with self.client.websocket_connect(pub_path) as pub: + pub.send_text('{"type":"tool.start","payload":{"tool_id":"t1"}}') + received = sub.receive_text() + + assert "tool.start" in received + assert '"tool_id":"t1"' in received + + def test_events_rejects_missing_channel(self): + from starlette.websockets import WebSocketDisconnect + + with pytest.raises(WebSocketDisconnect) as exc: + with self.client.websocket_connect( + f"/api/events?token={self.token}" + ): + pass + assert exc.value.code == 4400 diff --git a/tui_gateway/entry.py b/tui_gateway/entry.py index 7eac6057e..4e03224ee 100644 --- a/tui_gateway/entry.py +++ b/tui_gateway/entry.py @@ -5,7 +5,28 @@ import sys import time import traceback +from tui_gateway import server from tui_gateway.server import _CRASH_LOG, dispatch, resolve_skin, write_json +from tui_gateway.transport import TeeTransport + + +def _install_sidecar_publisher() -> None: + """Mirror every dispatcher emit to the dashboard sidebar via WS. + + Activated by `HERMES_TUI_SIDECAR_URL`, set by the dashboard's + ``/api/pty`` endpoint when a chat tab passes a ``channel`` query param. + Best-effort: connect failure or runtime drop falls back to stdio-only. + """ + url = os.environ.get("HERMES_TUI_SIDECAR_URL") + + if not url: + return + + from tui_gateway.event_publisher import WsPublisherTransport + + server._stdio_transport = TeeTransport( + server._stdio_transport, WsPublisherTransport(url) + ) def _log_signal(signum: int, frame) -> None: @@ -82,6 +103,8 @@ def _log_exit(reason: str) -> None: def main(): + _install_sidecar_publisher() + if not write_json({ "jsonrpc": "2.0", "method": "event", diff --git a/tui_gateway/event_publisher.py b/tui_gateway/event_publisher.py new file mode 100644 index 000000000..5e618bc21 --- /dev/null +++ b/tui_gateway/event_publisher.py @@ -0,0 +1,81 @@ +"""Best-effort WebSocket publisher transport for the PTY-side gateway. + +The dashboard's `/api/pty` spawns `hermes --tui` as a child process, which +spawns its own ``tui_gateway.entry``. Tool/reasoning/status events fire on +*that* gateway's transport — three processes removed from the dashboard +server itself. To surface them in the dashboard sidebar (`/api/events`), +the PTY-side gateway opens a back-WS to the dashboard at startup and +mirrors every emit through this transport. + +Wire protocol: newline-framed JSON dicts (the same shape the dispatcher +already passes to ``write``). No JSON-RPC envelope here — the dashboard's +``/api/pub`` endpoint just rebroadcasts the bytes verbatim to subscribers. + +Failure mode: silent. The agent loop must never block waiting for the +sidecar to drain. A dead WS short-circuits all subsequent writes. +""" + +from __future__ import annotations + +import json +import logging +import threading +from typing import Optional + +try: + from websockets.sync.client import connect as ws_connect +except ImportError: # pragma: no cover - websockets is a required install path + ws_connect = None # type: ignore[assignment] + +_log = logging.getLogger(__name__) + + +class WsPublisherTransport: + __slots__ = ("_url", "_lock", "_ws", "_dead") + + def __init__(self, url: str, *, connect_timeout: float = 2.0) -> None: + self._url = url + self._lock = threading.Lock() + self._ws: Optional[object] = None + self._dead = False + + if ws_connect is None: + self._dead = True + + return + + try: + self._ws = ws_connect(url, open_timeout=connect_timeout, max_size=None) + except Exception as exc: + _log.debug("event publisher connect failed: %s", exc) + self._dead = True + self._ws = None + + def write(self, obj: dict) -> bool: + if self._dead or self._ws is None: + return False + + try: + with self._lock: + self._ws.send(json.dumps(obj, ensure_ascii=False)) # type: ignore[union-attr] + + return True + except Exception as exc: + _log.debug("event publisher write failed: %s", exc) + self._dead = True + self._ws = None + + return False + + def close(self) -> None: + self._dead = True + + if self._ws is None: + return + + try: + self._ws.close() # type: ignore[union-attr] + except Exception: + pass + + self._ws = None diff --git a/tui_gateway/server.py b/tui_gateway/server.py index eea4ebf35..cc2d7b08d 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1,5 +1,6 @@ import atexit import concurrent.futures +import contextvars import copy import json import logging @@ -12,9 +13,17 @@ import time import uuid from datetime import datetime from pathlib import Path +from typing import Optional from hermes_constants import get_hermes_home from hermes_cli.env_loader import load_hermes_dotenv +from tui_gateway.transport import ( + StdioTransport, + Transport, + bind_transport, + current_transport, + reset_transport, +) logger = logging.getLogger(__name__) @@ -147,6 +156,11 @@ atexit.register(lambda: _pool.shutdown(wait=False, cancel_futures=True)) _real_stdout = sys.stdout sys.stdout = sys.stderr +# Module-level stdio transport — fallback sink when no transport is bound via +# contextvar or session. Stream resolved through a lambda so runtime monkey- +# patches of `_real_stdout` (used extensively in tests) still land correctly. +_stdio_transport = StdioTransport(lambda: _real_stdout, _stdout_lock) + class _SlashWorker: """Persistent HermesCLI subprocess for slash commands.""" @@ -266,14 +280,24 @@ def _db_unavailable_error(rid, *, code: int): def write_json(obj: dict) -> bool: - line = json.dumps(obj, ensure_ascii=False) + "\n" - try: - with _stdout_lock: - _real_stdout.write(line) - _real_stdout.flush() - return True - except BrokenPipeError: - return False + """Emit one JSON frame. Routes via the most-specific transport available. + + Precedence: + + 1. Event frames with a session id → the transport stored on that session, + so async events land with the client that owns the session even if + the emitting thread has no contextvar binding. + 2. Otherwise the transport bound on the current context (set by + :func:`dispatch` for the lifetime of a request). + 3. Otherwise the module-level stdio transport, matching the historical + behaviour and keeping tests that monkey-patch ``_real_stdout`` green. + """ + if obj.get("method") == "event": + sid = ((obj.get("params") or {}).get("session_id")) or "" + if sid and (t := (_sessions.get(sid) or {}).get("transport")) is not None: + return t.write(obj) + + return (current_transport() or _stdio_transport).write(obj) def _emit(event: str, sid: str, payload: dict | None = None): @@ -343,27 +367,40 @@ def handle_request(req: dict) -> dict | None: return fn(req.get("id"), req.get("params", {})) -def dispatch(req: dict) -> dict | None: +def dispatch(req: dict, transport: Optional[Transport] = None) -> dict | None: """Route inbound RPCs — long handlers to the pool, everything else inline. Returns a response dict when handled inline. Returns None when the - handler was scheduled on the pool; the worker writes its own - response via write_json when done. + handler was scheduled on the pool; the worker writes its own response + via the bound transport when done. + + *transport* (optional): pins every write produced by this request — + including any events emitted by the handler — to the given transport. + Omitting it falls back to the module-level stdio transport, preserving + the original behaviour for ``tui_gateway.entry``. """ - if req.get("method") not in _LONG_HANDLERS: - return handle_request(req) + t = transport or _stdio_transport + token = bind_transport(t) + try: + if req.get("method") not in _LONG_HANDLERS: + return handle_request(req) - def run(): - try: - resp = handle_request(req) - except Exception as exc: - resp = _err(req.get("id"), -32000, f"handler error: {exc}") - if resp is not None: - write_json(resp) + # Snapshot the context so the pool worker sees the bound transport. + ctx = contextvars.copy_context() - _pool.submit(run) + def run(): + try: + resp = handle_request(req) + except Exception as exc: + resp = _err(req.get("id"), -32000, f"handler error: {exc}") + if resp is not None: + t.write(resp) - return None + _pool.submit(lambda: ctx.run(run)) + + return None + finally: + reset_transport(token) def _wait_agent(session: dict, rid: str, timeout: float = 30.0) -> dict | None: @@ -1262,6 +1299,9 @@ def _init_session(sid: str, key: str, agent, history: list, cols: int = 80): "tool_progress_mode": _load_tool_progress_mode(), "edit_snapshots": {}, "tool_started_at": {}, + # Pin async event emissions to whichever transport created the + # session (stdio for Ink, JSON-RPC WS for the dashboard sidebar). + "transport": current_transport() or _stdio_transport, } try: _sessions[sid]["slash_worker"] = _SlashWorker( @@ -1404,6 +1444,7 @@ def _(rid, params: dict) -> dict: "slash_worker": None, "tool_progress_mode": _load_tool_progress_mode(), "tool_started_at": {}, + "transport": current_transport() or _stdio_transport, } def _build() -> None: diff --git a/tui_gateway/transport.py b/tui_gateway/transport.py new file mode 100644 index 000000000..f9c71f961 --- /dev/null +++ b/tui_gateway/transport.py @@ -0,0 +1,125 @@ +"""Transport abstraction for the tui_gateway JSON-RPC server. + +Historically the gateway wrote every JSON frame directly to real stdout. This +module decouples the I/O sink from the handler logic so the same dispatcher +can be driven over stdio (``tui_gateway.entry``) or WebSocket +(``tui_gateway.ws``) without duplicating code. + +A :class:`Transport` is anything that can accept a JSON-serialisable dict and +forward it to its peer. The active transport for the current request is +tracked in a :class:`contextvars.ContextVar` so handlers — including those +dispatched onto the worker pool — route their writes to the right peer. + +Backward compatibility +---------------------- +``tui_gateway.server.write_json`` still works without any transport bound. +When nothing is on the contextvar and no session-level transport is found, +it falls back to the module-level :class:`StdioTransport`, which wraps the +original ``_real_stdout`` + ``_stdout_lock`` pair. Tests that monkey-patch +``server._real_stdout`` continue to work because the stdio transport resolves +the stream lazily through a callback. +""" + +from __future__ import annotations + +import contextvars +import json +import threading +from typing import Any, Callable, Optional, Protocol, runtime_checkable + + +@runtime_checkable +class Transport(Protocol): + """Minimal interface every transport implements.""" + + def write(self, obj: dict) -> bool: + """Emit one JSON frame. Return ``False`` when the peer is gone.""" + + def close(self) -> None: + """Release any resources owned by this transport.""" + + +_current_transport: contextvars.ContextVar[Optional[Transport]] = ( + contextvars.ContextVar( + "hermes_gateway_transport", + default=None, + ) +) + + +def current_transport() -> Optional[Transport]: + """Return the transport bound for the current request, if any.""" + return _current_transport.get() + + +def bind_transport(transport: Optional[Transport]): + """Bind *transport* for the current context. Returns a token for :func:`reset_transport`.""" + return _current_transport.set(transport) + + +def reset_transport(token) -> None: + """Restore the transport binding captured by :func:`bind_transport`.""" + _current_transport.reset(token) + + +class StdioTransport: + """Writes JSON frames to a stream (usually ``sys.stdout``). + + The stream is resolved via a callable so runtime monkey-patches of the + underlying stream continue to work — this preserves the behaviour the + existing test suite relies on (``monkeypatch.setattr(server, "_real_stdout", ...)``). + """ + + __slots__ = ("_stream_getter", "_lock") + + def __init__(self, stream_getter: Callable[[], Any], lock: threading.Lock) -> None: + self._stream_getter = stream_getter + self._lock = lock + + def write(self, obj: dict) -> bool: + line = json.dumps(obj, ensure_ascii=False) + "\n" + try: + with self._lock: + stream = self._stream_getter() + stream.write(line) + stream.flush() + return True + except BrokenPipeError: + return False + + def close(self) -> None: + return None + + +class TeeTransport: + """Mirrors writes to one primary plus N best-effort secondaries. + + The primary's return value (and exceptions) determine the result — + secondaries swallow failures so a wedged sidecar never stalls the + main IO path. Used by the PTY child so every dispatcher emit lands + on stdio (Ink) AND on a back-WS feeding the dashboard sidebar. + """ + + __slots__ = ("_primary", "_secondaries") + + def __init__(self, primary: "Transport", *secondaries: "Transport") -> None: + self._primary = primary + self._secondaries = secondaries + + def write(self, obj: dict) -> bool: + for sec in self._secondaries: + try: + sec.write(obj) + except Exception: + pass + return self._primary.write(obj) + + def close(self) -> None: + try: + self._primary.close() + finally: + for sec in self._secondaries: + try: + sec.close() + except Exception: + pass diff --git a/tui_gateway/ws.py b/tui_gateway/ws.py new file mode 100644 index 000000000..1661811db --- /dev/null +++ b/tui_gateway/ws.py @@ -0,0 +1,174 @@ +"""WebSocket transport for the tui_gateway JSON-RPC server. + +Reuses :func:`tui_gateway.server.dispatch` verbatim so every RPC method, every +slash command, every approval/clarify/sudo flow, and every agent event flows +through the same handlers whether the client is Ink over stdio or an iOS / +web client over WebSocket. + +Wire protocol +------------- +Identical to stdio: newline-delimited JSON-RPC in both directions. The server +emits a ``gateway.ready`` event immediately after connection accept, then +echoes responses/events for inbound requests. No framing differences. + +Mounting +-------- + from fastapi import WebSocket + from tui_gateway.ws import handle_ws + + @app.websocket("/api/ws") + async def ws(ws: WebSocket): + await handle_ws(ws) +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from typing import Any + +from tui_gateway import server + +_log = logging.getLogger(__name__) + +# Max seconds a pool-dispatched handler will block waiting for the event loop +# to flush a WS frame before we mark the transport dead. Protects handler +# threads from a wedged socket. +_WS_WRITE_TIMEOUT_S = 10.0 + +# Keep starlette optional at import time; handle_ws uses the real class when +# it's available and falls back to a generic Exception sentinel otherwise. +try: + from starlette.websockets import WebSocketDisconnect as _WebSocketDisconnect +except ImportError: # pragma: no cover - starlette is a required install path + _WebSocketDisconnect = Exception # type: ignore[assignment] + + +class WSTransport: + """Per-connection WS transport. + + ``write`` is safe to call from any thread *other than* the event loop + thread that owns the socket. Pool workers (the only real caller) run in + their own threads, so marshalling onto the loop via + :func:`asyncio.run_coroutine_threadsafe` + ``future.result()`` is correct + and deadlock-free there. + + When called from the loop thread itself (e.g. by ``handle_ws`` for an + inline response) the same call would deadlock: we'd schedule work onto + the loop we're currently blocking. We detect that case and fire-and- + forget instead. Callers that need to know when the bytes are on the wire + should use :meth:`write_async` from the loop thread. + """ + + def __init__(self, ws: Any, loop: asyncio.AbstractEventLoop) -> None: + self._ws = ws + self._loop = loop + self._closed = False + + def write(self, obj: dict) -> bool: + if self._closed: + return False + + line = json.dumps(obj, ensure_ascii=False) + + try: + on_loop = asyncio.get_running_loop() is self._loop + except RuntimeError: + on_loop = False + + if on_loop: + # Fire-and-forget — don't block the loop waiting on itself. + self._loop.create_task(self._safe_send(line)) + return True + + try: + fut = asyncio.run_coroutine_threadsafe(self._safe_send(line), self._loop) + fut.result(timeout=_WS_WRITE_TIMEOUT_S) + return not self._closed + except Exception as exc: + self._closed = True + _log.debug("ws write failed: %s", exc) + return False + + async def write_async(self, obj: dict) -> bool: + """Send from the owning event loop. Awaits until the frame is on the wire.""" + if self._closed: + return False + await self._safe_send(json.dumps(obj, ensure_ascii=False)) + return not self._closed + + async def _safe_send(self, line: str) -> None: + try: + await self._ws.send_text(line) + except Exception as exc: + self._closed = True + _log.debug("ws send failed: %s", exc) + + def close(self) -> None: + self._closed = True + + +async def handle_ws(ws: Any) -> None: + """Run one WebSocket session. Wire-compatible with ``tui_gateway.entry``.""" + await ws.accept() + + transport = WSTransport(ws, asyncio.get_running_loop()) + + await transport.write_async( + { + "jsonrpc": "2.0", + "method": "event", + "params": { + "type": "gateway.ready", + "payload": {"skin": server.resolve_skin()}, + }, + } + ) + + try: + while True: + try: + raw = await ws.receive_text() + except _WebSocketDisconnect: + break + + line = raw.strip() + if not line: + continue + + try: + req = json.loads(line) + except json.JSONDecodeError: + ok = await transport.write_async( + { + "jsonrpc": "2.0", + "error": {"code": -32700, "message": "parse error"}, + "id": None, + } + ) + if not ok: + break + continue + + # dispatch() may schedule long handlers on the pool; it returns + # None in that case and the worker writes the response itself via + # the transport we pass in (a separate thread, so transport.write + # is the safe path there). For inline handlers it returns the + # response dict, which we write here from the loop. + resp = await asyncio.to_thread(server.dispatch, req, transport) + if resp is not None and not await transport.write_async(resp): + break + finally: + transport.close() + + # Detach the transport from any sessions it owned so later emits + # fall back to stdio instead of crashing into a closed socket. + for _, sess in list(server._sessions.items()): + if sess.get("transport") is transport: + sess["transport"] = server._stdio_transport + + try: + await ws.close() + except Exception: + pass diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index efea1c112..870e2000c 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -246,7 +246,7 @@ export const coreCommands: SlashCommand[] = [ } writeOsc52Clipboard(target.text) - sys('sent OSC52 copy sequence (terminal support required)') + sys(`copied ${target.text.length} chars`) } }, diff --git a/web/package-lock.json b/web/package-lock.json index bc806a371..436b17bb7 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", @@ -213,23 +218,23 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/types": "^7.29.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", "dev": true, "license": "MIT", "dependencies": { @@ -332,9 +337,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", - "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", "cpu": [ "ppc64" ], @@ -348,9 +353,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", - "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", "cpu": [ "arm" ], @@ -364,9 +369,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", - "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", "cpu": [ "arm64" ], @@ -380,9 +385,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", - "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", "cpu": [ "x64" ], @@ -396,9 +401,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", - "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", "cpu": [ "arm64" ], @@ -412,9 +417,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", - "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", "cpu": [ "x64" ], @@ -428,9 +433,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", - "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", "cpu": [ "arm64" ], @@ -444,9 +449,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", - "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", "cpu": [ "x64" ], @@ -460,9 +465,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", - "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", "cpu": [ "arm" ], @@ -476,9 +481,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", - "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", "cpu": [ "arm64" ], @@ -492,9 +497,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", - "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", "cpu": [ "ia32" ], @@ -508,9 +513,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", - "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", "cpu": [ "loong64" ], @@ -524,9 +529,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", - "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", "cpu": [ "mips64el" ], @@ -540,9 +545,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", - "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", "cpu": [ "ppc64" ], @@ -556,9 +561,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", - "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", "cpu": [ "riscv64" ], @@ -572,9 +577,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", - "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", "cpu": [ "s390x" ], @@ -588,9 +593,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", - "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", "cpu": [ "x64" ], @@ -604,9 +609,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", - "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", "cpu": [ "arm64" ], @@ -620,9 +625,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", - "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", "cpu": [ "x64" ], @@ -636,9 +641,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", - "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", "cpu": [ "arm64" ], @@ -652,9 +657,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", - "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", "cpu": [ "x64" ], @@ -668,9 +673,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", - "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", "cpu": [ "arm64" ], @@ -684,9 +689,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", - "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", "cpu": [ "x64" ], @@ -700,9 +705,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", - "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", "cpu": [ "arm64" ], @@ -716,9 +721,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", - "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", "cpu": [ "ia32" ], @@ -732,9 +737,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", - "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", "cpu": [ "x64" ], @@ -943,29 +948,43 @@ "license": "MIT" }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -1801,35 +1820,6 @@ } } }, - "node_modules/@react-three/fiber/node_modules/zustand": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", - "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", - "license": "MIT", - "engines": { - "node": ">=12.20.0" - }, - "peerDependencies": { - "@types/react": ">=18.0.0", - "immer": ">=9.0.6", - "react": ">=18.0.0", - "use-sync-external-store": ">=1.2.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true - }, - "use-sync-external-store": { - "optional": true - } - } - }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -1838,9 +1828,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", - "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", "cpu": [ "arm" ], @@ -1851,9 +1841,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", - "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", "cpu": [ "arm64" ], @@ -1864,9 +1854,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", - "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", "cpu": [ "arm64" ], @@ -1877,9 +1867,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", - "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", "cpu": [ "x64" ], @@ -1890,9 +1880,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", - "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", "cpu": [ "arm64" ], @@ -1903,9 +1893,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", - "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", "cpu": [ "x64" ], @@ -1916,9 +1906,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", - "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", "cpu": [ "arm" ], @@ -1929,9 +1919,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", - "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", "cpu": [ "arm" ], @@ -1942,9 +1932,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", - "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", "cpu": [ "arm64" ], @@ -1955,9 +1945,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", - "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", "cpu": [ "arm64" ], @@ -1968,9 +1958,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", - "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", "cpu": [ "loong64" ], @@ -1981,9 +1971,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", - "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", "cpu": [ "loong64" ], @@ -1994,9 +1984,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", - "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", "cpu": [ "ppc64" ], @@ -2007,9 +1997,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", - "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", "cpu": [ "ppc64" ], @@ -2020,9 +2010,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", - "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", "cpu": [ "riscv64" ], @@ -2033,9 +2023,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", - "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", "cpu": [ "riscv64" ], @@ -2046,9 +2036,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", - "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", "cpu": [ "s390x" ], @@ -2059,9 +2049,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", - "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", "cpu": [ "x64" ], @@ -2072,9 +2062,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", - "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", "cpu": [ "x64" ], @@ -2085,9 +2075,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", - "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", "cpu": [ "x64" ], @@ -2098,9 +2088,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", - "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", "cpu": [ "arm64" ], @@ -2111,9 +2101,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", - "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", "cpu": [ "arm64" ], @@ -2124,9 +2114,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", - "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", "cpu": [ "ia32" ], @@ -2137,9 +2127,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", - "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", "cpu": [ "x64" ], @@ -2150,9 +2140,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", - "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", "cpu": [ "x64" ], @@ -2172,47 +2162,47 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", - "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz", + "integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==", "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", - "lightningcss": "1.31.1", + "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.2.1" + "tailwindcss": "4.2.4" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz", - "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz", + "integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==", "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.2.1", - "@tailwindcss/oxide-darwin-arm64": "4.2.1", - "@tailwindcss/oxide-darwin-x64": "4.2.1", - "@tailwindcss/oxide-freebsd-x64": "4.2.1", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", - "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", - "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", - "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", - "@tailwindcss/oxide-linux-x64-musl": "4.2.1", - "@tailwindcss/oxide-wasm32-wasi": "4.2.1", - "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", - "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" + "@tailwindcss/oxide-android-arm64": "4.2.4", + "@tailwindcss/oxide-darwin-arm64": "4.2.4", + "@tailwindcss/oxide-darwin-x64": "4.2.4", + "@tailwindcss/oxide-freebsd-x64": "4.2.4", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", + "@tailwindcss/oxide-linux-x64-musl": "4.2.4", + "@tailwindcss/oxide-wasm32-wasi": "4.2.4", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz", - "integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz", + "integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==", "cpu": [ "arm64" ], @@ -2226,9 +2216,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz", - "integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz", + "integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==", "cpu": [ "arm64" ], @@ -2242,9 +2232,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz", - "integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz", + "integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==", "cpu": [ "x64" ], @@ -2258,9 +2248,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz", - "integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz", + "integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==", "cpu": [ "x64" ], @@ -2274,9 +2264,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz", - "integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz", + "integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==", "cpu": [ "arm" ], @@ -2290,9 +2280,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz", - "integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz", + "integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==", "cpu": [ "arm64" ], @@ -2306,9 +2296,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz", - "integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz", + "integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==", "cpu": [ "arm64" ], @@ -2322,9 +2312,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz", - "integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz", + "integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==", "cpu": [ "x64" ], @@ -2338,9 +2328,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz", - "integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz", + "integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==", "cpu": [ "x64" ], @@ -2354,9 +2344,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz", - "integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz", + "integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -2383,9 +2373,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz", - "integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz", + "integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==", "cpu": [ "arm64" ], @@ -2399,9 +2389,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz", - "integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz", + "integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==", "cpu": [ "x64" ], @@ -2415,17 +2405,17 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.1.tgz", - "integrity": "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.4.tgz", + "integrity": "sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==", "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.2.1", - "@tailwindcss/oxide": "4.2.1", - "tailwindcss": "4.2.1" + "@tailwindcss/node": "4.2.4", + "@tailwindcss/oxide": "4.2.4", + "tailwindcss": "4.2.4" }, "peerDependencies": { - "vite": "^5.2.0 || ^6 || ^7" + "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "node_modules/@types/babel__core": { @@ -2487,9 +2477,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.12.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", - "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", "devOptional": true, "license": "MIT", "peer": true, @@ -2534,20 +2524,20 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.0.tgz", - "integrity": "sha512-qeu4rTHR3/IaFORbD16gmjq9+rEs9fGKdX0kF6BKSfi+gCuG3RCKLlSBYzn/bGsY9Tj7KE/DAQStbp8AHJGHEQ==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.0.tgz", + "integrity": "sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.57.0", - "@typescript-eslint/type-utils": "8.57.0", - "@typescript-eslint/utils": "8.57.0", - "@typescript-eslint/visitor-keys": "8.57.0", + "@typescript-eslint/scope-manager": "8.59.0", + "@typescript-eslint/type-utils": "8.59.0", + "@typescript-eslint/utils": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2557,9 +2547,9 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.57.0", + "@typescript-eslint/parser": "^8.59.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { @@ -2573,17 +2563,17 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.0.tgz", - "integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.0.tgz", + "integrity": "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.57.0", - "@typescript-eslint/types": "8.57.0", - "@typescript-eslint/typescript-estree": "8.57.0", - "@typescript-eslint/visitor-keys": "8.57.0", + "@typescript-eslint/scope-manager": "8.59.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0", "debug": "^4.4.3" }, "engines": { @@ -2595,18 +2585,18 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.0.tgz", - "integrity": "sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.0.tgz", + "integrity": "sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.57.0", - "@typescript-eslint/types": "^8.57.0", + "@typescript-eslint/tsconfig-utils": "^8.59.0", + "@typescript-eslint/types": "^8.59.0", "debug": "^4.4.3" }, "engines": { @@ -2617,18 +2607,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.0.tgz", - "integrity": "sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.0.tgz", + "integrity": "sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.0", - "@typescript-eslint/visitor-keys": "8.57.0" + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2639,9 +2629,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.0.tgz", - "integrity": "sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.0.tgz", + "integrity": "sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg==", "dev": true, "license": "MIT", "engines": { @@ -2652,21 +2642,21 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.0.tgz", - "integrity": "sha512-yjgh7gmDcJ1+TcEg8x3uWQmn8ifvSupnPfjP21twPKrDP/pTHlEQgmKcitzF/rzPSmv7QjJ90vRpN4U+zoUjwQ==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.0.tgz", + "integrity": "sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.0", - "@typescript-eslint/typescript-estree": "8.57.0", - "@typescript-eslint/utils": "8.57.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0", + "@typescript-eslint/utils": "8.59.0", "debug": "^4.4.3", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2677,13 +2667,13 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.0.tgz", - "integrity": "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.0.tgz", + "integrity": "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A==", "dev": true, "license": "MIT", "engines": { @@ -2695,21 +2685,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.0.tgz", - "integrity": "sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.0.tgz", + "integrity": "sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.57.0", - "@typescript-eslint/tsconfig-utils": "8.57.0", - "@typescript-eslint/types": "8.57.0", - "@typescript-eslint/visitor-keys": "8.57.0", + "@typescript-eslint/project-service": "8.59.0", + "@typescript-eslint/tsconfig-utils": "8.59.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2719,7 +2709,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { @@ -2733,9 +2723,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2746,13 +2736,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.5" }, "engines": { "node": "18 || 20 || >=22" @@ -2775,16 +2765,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.0.tgz", - "integrity": "sha512-5iIHvpD3CZe06riAsbNxxreP+MuYgVUsV0n4bwLH//VJmgtt54sQeY2GszntJ4BjYCpMzrfVh2SBnUQTtys2lQ==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.0.tgz", + "integrity": "sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.57.0", - "@typescript-eslint/types": "8.57.0", - "@typescript-eslint/typescript-estree": "8.57.0" + "@typescript-eslint/scope-manager": "8.59.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2795,17 +2785,17 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.0.tgz", - "integrity": "sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.0.tgz", + "integrity": "sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/types": "8.59.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -2868,6 +2858,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", @@ -2893,9 +2916,9 @@ } }, "node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -2978,9 +3001,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.7", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.7.tgz", - "integrity": "sha512-1ghYO3HnxGec0TCGBXiDLVns4eCSx4zJpxnHrlqFQajmhfKMQBzUGDdkMK7fUW7PTHTeLf+j87aTuKuuwWzMGw==", + "version": "2.10.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.21.tgz", + "integrity": "sha512-Q+rUQ7Uz8AHM7DEaNdwvfFCTq7a43lNTzuS94eiWqwyxfV/wJv+oUivef51T91mmRY4d4A1u9rcSvkeufCVXlA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2997,9 +3020,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -3008,9 +3031,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "dev": true, "funding": [ { @@ -3029,11 +3052,11 @@ "license": "MIT", "peer": true, "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -3077,9 +3100,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001778", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001778.tgz", - "integrity": "sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg==", + "version": "1.0.30001790", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001790.tgz", + "integrity": "sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw==", "dev": true, "funding": [ { @@ -3749,20 +3772,20 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.313", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz", - "integrity": "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==", + "version": "1.5.344", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", + "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==", "dev": true, "license": "ISC" }, "node_modules/enhanced-resolve": { - "version": "5.20.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", - "integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==", + "version": "5.21.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", + "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.3.0" + "tapable": "^2.3.3" }, "engines": { "node": ">=10.13.0" @@ -3781,9 +3804,9 @@ } }, "node_modules/esbuild": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", - "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", "hasInstallScript": true, "license": "MIT", "bin": { @@ -3793,32 +3816,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.4", - "@esbuild/android-arm": "0.27.4", - "@esbuild/android-arm64": "0.27.4", - "@esbuild/android-x64": "0.27.4", - "@esbuild/darwin-arm64": "0.27.4", - "@esbuild/darwin-x64": "0.27.4", - "@esbuild/freebsd-arm64": "0.27.4", - "@esbuild/freebsd-x64": "0.27.4", - "@esbuild/linux-arm": "0.27.4", - "@esbuild/linux-arm64": "0.27.4", - "@esbuild/linux-ia32": "0.27.4", - "@esbuild/linux-loong64": "0.27.4", - "@esbuild/linux-mips64el": "0.27.4", - "@esbuild/linux-ppc64": "0.27.4", - "@esbuild/linux-riscv64": "0.27.4", - "@esbuild/linux-s390x": "0.27.4", - "@esbuild/linux-x64": "0.27.4", - "@esbuild/netbsd-arm64": "0.27.4", - "@esbuild/netbsd-x64": "0.27.4", - "@esbuild/openbsd-arm64": "0.27.4", - "@esbuild/openbsd-x64": "0.27.4", - "@esbuild/openharmony-arm64": "0.27.4", - "@esbuild/sunos-x64": "0.27.4", - "@esbuild/win32-arm64": "0.27.4", - "@esbuild/win32-ia32": "0.27.4", - "@esbuild/win32-x64": "0.27.4" + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" } }, "node_modules/escalade": { @@ -3905,9 +3928,9 @@ } }, "node_modules/eslint-plugin-react-hooks": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", - "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", "dev": true, "license": "MIT", "dependencies": { @@ -3921,7 +3944,7 @@ "node": ">=18" }, "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "node_modules/eslint-plugin-react-refresh": { @@ -4144,9 +4167,9 @@ } }, "node_modules/flatted": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", - "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -4206,9 +4229,9 @@ } }, "node_modules/globals": { - "version": "17.4.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz", - "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==", + "version": "17.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.5.0.tgz", + "integrity": "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==", "dev": true, "license": "MIT", "engines": { @@ -4376,18 +4399,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-extendable/node_modules/is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "license": "MIT", - "dependencies": { - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -4412,10 +4423,13 @@ } }, "node_modules/is-plain-object": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", - "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, "engines": { "node": ">=0.10.0" } @@ -4563,6 +4577,23 @@ "react-dom": "^18.0.0 || ^19.0.0" } }, + "node_modules/leva/node_modules/zustand": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-3.7.2.tgz", + "integrity": "sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA==", + "license": "MIT", + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -4578,9 +4609,9 @@ } }, "node_modules/lightningcss": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", - "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -4593,23 +4624,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.31.1", - "lightningcss-darwin-arm64": "1.31.1", - "lightningcss-darwin-x64": "1.31.1", - "lightningcss-freebsd-x64": "1.31.1", - "lightningcss-linux-arm-gnueabihf": "1.31.1", - "lightningcss-linux-arm64-gnu": "1.31.1", - "lightningcss-linux-arm64-musl": "1.31.1", - "lightningcss-linux-x64-gnu": "1.31.1", - "lightningcss-linux-x64-musl": "1.31.1", - "lightningcss-win32-arm64-msvc": "1.31.1", - "lightningcss-win32-x64-msvc": "1.31.1" + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" } }, - "node_modules/lightningcss/node_modules/lightningcss-android-arm64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", - "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", "cpu": [ "arm64" ], @@ -4626,10 +4657,10 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/lightningcss/node_modules/lightningcss-darwin-arm64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", - "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", "cpu": [ "arm64" ], @@ -4646,10 +4677,10 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/lightningcss/node_modules/lightningcss-darwin-x64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", - "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", "cpu": [ "x64" ], @@ -4666,10 +4697,10 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/lightningcss/node_modules/lightningcss-freebsd-x64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", - "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", "cpu": [ "x64" ], @@ -4686,10 +4717,10 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/lightningcss/node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", - "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", "cpu": [ "arm" ], @@ -4706,10 +4737,10 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/lightningcss/node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", - "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", "cpu": [ "arm64" ], @@ -4726,10 +4757,10 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/lightningcss/node_modules/lightningcss-linux-arm64-musl": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", - "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", "cpu": [ "arm64" ], @@ -4746,10 +4777,10 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/lightningcss/node_modules/lightningcss-linux-x64-gnu": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", - "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", "cpu": [ "x64" ], @@ -4766,10 +4797,10 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/lightningcss/node_modules/lightningcss-linux-x64-musl": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", - "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", "cpu": [ "x64" ], @@ -4786,10 +4817,10 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/lightningcss/node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", - "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", "cpu": [ "arm64" ], @@ -4806,10 +4837,10 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/lightningcss/node_modules/lightningcss-win32-x64-msvc": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", - "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", "cpu": [ "x64" ], @@ -4979,9 +5010,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.36", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", - "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", "dev": true, "license": "MIT" }, @@ -5090,9 +5121,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "peer": true, "engines": { @@ -5103,9 +5134,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", "funding": [ { "type": "opencollective", @@ -5162,9 +5193,9 @@ } }, "node_modules/react": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", - "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", "peer": true, "engines": { @@ -5182,16 +5213,16 @@ } }, "node_modules/react-dom": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", - "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", "license": "MIT", "peer": true, "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.4" + "react": "^19.2.5" } }, "node_modules/react-dropzone": { @@ -5228,9 +5259,9 @@ } }, "node_modules/react-router": { - "version": "7.14.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.1.tgz", - "integrity": "sha512-5BCvFskyAAVumqhEKh/iPhLOIkfxcEUz8WqFIARCkMg8hZZzDYX9CtwxXA0e+qT8zAxmMC0x3Ckb9iMONwc5jg==", + "version": "7.14.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz", + "integrity": "sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -5250,12 +5281,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.14.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.1.tgz", - "integrity": "sha512-ZkrQuwwhGibjQLqH1eCdyiZyLWglPxzxdl5tgwgKEyCSGC76vmAjleGocRe3J/MLfzMUIKwaFJWpFVJhK3d2xA==", + "version": "7.14.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.2.tgz", + "integrity": "sha512-YZcM5ES8jJSM+KrJ9BdvHHqlnGTg5tH3sC5ChFRj4inosKctdyzBDhOyyHdGk597q2OT6NTrCA1OvB/YDwfekQ==", "license": "MIT", "dependencies": { - "react-router": "7.14.1" + "react-router": "7.14.2" }, "engines": { "node": ">=20.0.0" @@ -5297,9 +5328,9 @@ "license": "Unlicense" }, "node_modules/rollup": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", - "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -5312,31 +5343,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.59.0", - "@rollup/rollup-android-arm64": "4.59.0", - "@rollup/rollup-darwin-arm64": "4.59.0", - "@rollup/rollup-darwin-x64": "4.59.0", - "@rollup/rollup-freebsd-arm64": "4.59.0", - "@rollup/rollup-freebsd-x64": "4.59.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", - "@rollup/rollup-linux-arm-musleabihf": "4.59.0", - "@rollup/rollup-linux-arm64-gnu": "4.59.0", - "@rollup/rollup-linux-arm64-musl": "4.59.0", - "@rollup/rollup-linux-loong64-gnu": "4.59.0", - "@rollup/rollup-linux-loong64-musl": "4.59.0", - "@rollup/rollup-linux-ppc64-gnu": "4.59.0", - "@rollup/rollup-linux-ppc64-musl": "4.59.0", - "@rollup/rollup-linux-riscv64-gnu": "4.59.0", - "@rollup/rollup-linux-riscv64-musl": "4.59.0", - "@rollup/rollup-linux-s390x-gnu": "4.59.0", - "@rollup/rollup-linux-x64-gnu": "4.59.0", - "@rollup/rollup-linux-x64-musl": "4.59.0", - "@rollup/rollup-openbsd-x64": "4.59.0", - "@rollup/rollup-openharmony-arm64": "4.59.0", - "@rollup/rollup-win32-arm64-msvc": "4.59.0", - "@rollup/rollup-win32-ia32-msvc": "4.59.0", - "@rollup/rollup-win32-x64-gnu": "4.59.0", - "@rollup/rollup-win32-x64-msvc": "4.59.0", + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", "fsevents": "~2.3.2" } }, @@ -5366,6 +5397,15 @@ "postcss": "^8.3.11" } }, + "node_modules/sanitize-html/node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -5412,18 +5452,6 @@ "node": ">=0.10.0" } }, - "node_modules/set-value/node_modules/is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "license": "MIT", - "dependencies": { - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -5527,15 +5555,15 @@ } }, "node_modules/tailwindcss": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", - "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", + "integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==", "license": "MIT" }, "node_modules/tapable": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", - "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "license": "MIT", "engines": { "node": ">=6" @@ -5553,13 +5581,13 @@ "peer": true }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -5569,9 +5597,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", - "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { @@ -5625,16 +5653,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.0.tgz", - "integrity": "sha512-W8GcigEMEeB07xEZol8oJ26rigm3+bfPHxHvwbYUlu1fUDsGuQ7Hiskx5xGW/xM4USc9Ephe3jtv7ZYPQntHeA==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.0.tgz", + "integrity": "sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.57.0", - "@typescript-eslint/parser": "8.57.0", - "@typescript-eslint/typescript-estree": "8.57.0", - "@typescript-eslint/utils": "8.57.0" + "@typescript-eslint/eslint-plugin": "8.59.0", + "@typescript-eslint/parser": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0", + "@typescript-eslint/utils": "8.59.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5645,7 +5673,7 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/undici-types": { @@ -5713,9 +5741,9 @@ "license": "MIT" }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "license": "MIT", "peer": true, "dependencies": { @@ -5858,19 +5886,31 @@ } }, "node_modules/zustand": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-3.7.2.tgz", - "integrity": "sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", + "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", "license": "MIT", "engines": { - "node": ">=12.7.0" + "node": ">=12.20.0" }, "peerDependencies": { - "react": ">=16.8" + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" }, "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, "react": { "optional": true + }, + "use-sync-external-store": { + "optional": true } } } diff --git a/web/package.json b/web/package.json index 5ca2288ef..8dfac7866 100644 --- a/web/package.json +++ b/web/package.json @@ -17,6 +17,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", diff --git a/web/public/fonts-terminal/JetBrainsMono-Bold.woff2 b/web/public/fonts-terminal/JetBrainsMono-Bold.woff2 new file mode 100644 index 000000000..81c5a219d Binary files /dev/null and b/web/public/fonts-terminal/JetBrainsMono-Bold.woff2 differ diff --git a/web/public/fonts-terminal/JetBrainsMono-Italic.woff2 b/web/public/fonts-terminal/JetBrainsMono-Italic.woff2 new file mode 100644 index 000000000..4103d3910 Binary files /dev/null and b/web/public/fonts-terminal/JetBrainsMono-Italic.woff2 differ diff --git a/web/public/fonts-terminal/JetBrainsMono-Regular.woff2 b/web/public/fonts-terminal/JetBrainsMono-Regular.woff2 new file mode 100644 index 000000000..66c54672c Binary files /dev/null and b/web/public/fonts-terminal/JetBrainsMono-Regular.woff2 differ diff --git a/web/src/App.tsx b/web/src/App.tsx index 3e68cc6c1..5434a0197 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -58,6 +58,7 @@ import LogsPage from "@/pages/LogsPage"; import AnalyticsPage from "@/pages/AnalyticsPage"; import CronPage from "@/pages/CronPage"; import SkillsPage from "@/pages/SkillsPage"; +import ChatPage from "@/pages/ChatPage"; import { LanguageSwitcher } from "@/components/LanguageSwitcher"; import { ThemeSwitcher } from "@/components/ThemeSwitcher"; import { useI18n } from "@/i18n"; @@ -72,6 +73,7 @@ function RootRedirect() { /** Built-in route → page component. Used for routing and for plugin `tab.path` / `tab.override` resolution. */ const BUILTIN_ROUTES: Record = { "/": RootRedirect, + "/chat": ChatPage, "/sessions": SessionsPage, "/analytics": AnalyticsPage, "/logs": LogsPage, @@ -83,6 +85,7 @@ const BUILTIN_ROUTES: Record = { }; const BUILTIN_NAV: NavItem[] = [ + { path: "/chat", labelKey: "chat", label: "Chat", icon: Terminal }, { path: "/sessions", labelKey: "sessions", diff --git a/web/src/components/ChatSidebar.tsx b/web/src/components/ChatSidebar.tsx new file mode 100644 index 000000000..924edc0e2 --- /dev/null +++ b/web/src/components/ChatSidebar.tsx @@ -0,0 +1,360 @@ +/** + * ChatSidebar — structured-events panel that sits next to the xterm.js + * terminal in the dashboard Chat tab. + * + * Two WebSockets, one per concern: + * + * 1. **JSON-RPC sidecar** (`GatewayClient` → /api/ws) — drives the + * sidebar's own slot of the dashboard's in-process gateway. Owns + * the model badge / picker / connection state / error banner. + * Independent of the PTY pane's session by design — those are the + * pieces the sidebar needs to be able to drive directly (model + * switch via slash.exec, etc.). + * + * 2. **Event subscriber** (/api/events?channel=…) — passive, receives + * every dispatcher emit from the PTY-side `tui_gateway.entry` that + * the dashboard fanned out. This is how `tool.start/progress/ + * complete` from the agent loop reach the sidebar even though the + * PTY child runs three processes deep from us. The `channel` id + * ties this listener to the same chat tab's PTY child — see + * `ChatPage.tsx` for where the id is generated. + * + * Best-effort throughout: WS failures show in the badge / banner, the + * terminal pane keeps working unimpaired. + */ + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; + +import { ModelPickerDialog } from "@/components/ModelPickerDialog"; +import { ToolCall, type ToolEntry } from "@/components/ToolCall"; +import { GatewayClient, type ConnectionState } from "@/lib/gatewayClient"; + +import { AlertCircle, ChevronDown, RefreshCw } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +interface SessionInfo { + cwd?: string; + model?: string; + provider?: string; + credential_warning?: string; +} + +interface RpcEnvelope { + method?: string; + params?: { type?: string; payload?: unknown }; +} + +const TOOL_LIMIT = 20; + +const STATE_LABEL: Record = { + idle: "idle", + connecting: "connecting", + open: "live", + closed: "closed", + error: "error", +}; + +const STATE_TONE: Record = { + idle: "bg-muted text-muted-foreground", + connecting: "bg-primary/10 text-primary", + open: "bg-emerald-500/10 text-emerald-500 dark:text-emerald-400", + closed: "bg-muted text-muted-foreground", + error: "bg-destructive/10 text-destructive", +}; + +interface ChatSidebarProps { + channel: string; +} + +export function ChatSidebar({ channel }: ChatSidebarProps) { + // `version` bumps on reconnect; gw is derived so we never call setState + // for it inside an effect (React 19's set-state-in-effect rule). The + // counter is the dependency on purpose — it's not read in the memo body, + // it's the signal that says "rebuild the client". + const [version, setVersion] = useState(0); + // eslint-disable-next-line react-hooks/exhaustive-deps + const gw = useMemo(() => new GatewayClient(), [version]); + + const [state, setState] = useState("idle"); + const [sessionId, setSessionId] = useState(null); + const [info, setInfo] = useState({}); + const [tools, setTools] = useState([]); + const [modelOpen, setModelOpen] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + const offState = gw.onState(setState); + + const offSessionInfo = gw.on("session.info", (ev) => { + if (ev.session_id) { + setSessionId(ev.session_id); + } + + if (ev.payload) { + setInfo((prev) => ({ ...prev, ...ev.payload })); + } + }); + + const offError = gw.on<{ message?: string }>("error", (ev) => { + const message = ev.payload?.message; + + if (message) { + setError(message); + } + }); + + // Adopt whichever session the gateway hands us. session.create on the + // sidecar is independent of the PTY pane's session by design — we + // only need a sid to drive the model picker's slash.exec calls. + gw.connect() + .then(() => gw.request<{ session_id: string }>("session.create", {})) + .then((created) => { + if (created?.session_id) { + setSessionId(created.session_id); + } + }) + .catch((e: Error) => setError(e.message)); + + return () => { + offState(); + offSessionInfo(); + offError(); + gw.close(); + }; + }, [gw]); + + // Event subscriber WebSocket — receives the rebroadcast of every + // dispatcher emit from the PTY child's gateway. See /api/pub + + // /api/events in hermes_cli/web_server.py for the broadcast hop. + // + // Failures (auth/loopback rejection, server too old to expose the + // endpoint, transient drops) surface in the same banner as the + // JSON-RPC sidecar so the sidebar matches its documented best-effort + // UX and the user always has a reconnect affordance. + useEffect(() => { + const token = window.__HERMES_SESSION_TOKEN__; + + if (!token || !channel) { + return; + } + + const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; + const qs = new URLSearchParams({ token, channel }); + const ws = new WebSocket( + `${proto}//${window.location.host}/api/events?${qs.toString()}`, + ); + + // `unmounting` suppresses the banner during cleanup — `ws.close()` + // from the effect's return fires a close event with code 1005 that + // would otherwise look like an unexpected drop. + const DISCONNECTED = "events feed disconnected — tool calls may not appear"; + let unmounting = false; + const surface = (msg: string) => !unmounting && setError(msg); + + ws.addEventListener("error", () => surface(DISCONNECTED)); + + ws.addEventListener("close", (ev) => { + if (ev.code === 4401 || ev.code === 4403) { + surface(`events feed rejected (${ev.code}) — reload the page`); + } else if (ev.code !== 1000) { + surface(DISCONNECTED); + } + }); + + ws.addEventListener("message", (ev) => { + let frame: RpcEnvelope; + + try { + frame = JSON.parse(ev.data); + } catch { + return; + } + + if (frame.method !== "event" || !frame.params) { + return; + } + + const { type, payload } = frame.params; + + if (type === "tool.start") { + const p = payload as + | { tool_id?: string; name?: string; context?: string } + | undefined; + const toolId = p?.tool_id; + + if (!toolId) { + return; + } + + setTools((prev) => + [ + ...prev, + { + kind: "tool" as const, + id: `tool-${toolId}-${prev.length}`, + tool_id: toolId, + name: p?.name ?? "tool", + context: p?.context, + status: "running" as const, + startedAt: Date.now(), + }, + ].slice(-TOOL_LIMIT), + ); + } else if (type === "tool.progress") { + const p = payload as + | { name?: string; preview?: string } + | undefined; + + if (!p?.name || !p.preview) { + return; + } + + setTools((prev) => + prev.map((t) => + t.status === "running" && t.name === p.name + ? { ...t, preview: p.preview } + : t, + ), + ); + } else if (type === "tool.complete") { + const p = payload as + | { + tool_id?: string; + summary?: string; + error?: string; + inline_diff?: string; + } + | undefined; + + if (!p?.tool_id) { + return; + } + + setTools((prev) => + prev.map((t) => + t.tool_id === p.tool_id + ? { + ...t, + status: p.error ? "error" : "done", + summary: p.summary, + error: p.error, + inline_diff: p.inline_diff, + completedAt: Date.now(), + } + : t, + ), + ); + } + }); + + return () => { + unmounting = true; + ws.close(); + }; + }, [channel, version]); + + const reconnect = useCallback(() => { + setError(null); + setTools([]); + setVersion((v) => v + 1); + }, []); + + // Picker hands us a fully-formed slash command (e.g. "/model anthropic/..."). + // Fire-and-forget through `slash.exec`; the TUI pane will render the result + // via PTY, so the sidebar doesn't need to surface output of its own. + const onModelSubmit = useCallback( + (slashCommand: string) => { + if (!sessionId) { + return; + } + + void gw.request("slash.exec", { + session_id: sessionId, + command: slashCommand, + }); + setModelOpen(false); + }, + [gw, sessionId], + ); + + const canPickModel = state === "open" && !!sessionId; + const modelLabel = (info.model ?? "—").split("/").slice(-1)[0] ?? "—"; + const banner = error ?? info.credential_warning ?? null; + + return ( + + ); +} diff --git a/web/src/components/Markdown.tsx b/web/src/components/Markdown.tsx index b796ff0a7..bef0804e7 100644 --- a/web/src/components/Markdown.tsx +++ b/web/src/components/Markdown.tsx @@ -1,22 +1,50 @@ -import { useMemo } from "react"; +import { useMemo, type ReactNode } from "react"; /** * Lightweight markdown renderer for LLM output. * Handles: code blocks, inline code, bold, italic, headers, links, lists, horizontal rules. * NOT a full CommonMark parser — optimized for typical assistant message patterns. + * + * `streaming` renders a blinking caret at the tail of the last block so it + * appears to hug the final character instead of wrapping onto a new line + * after a block element (paragraph/list/code/…). */ -export function Markdown({ content, highlightTerms }: { content: string; highlightTerms?: string[] }) { +export function Markdown({ + content, + highlightTerms, + streaming, +}: { + content: string; + highlightTerms?: string[]; + streaming?: boolean; +}) { const blocks = useMemo(() => parseBlocks(content), [content]); + const caret = streaming ? : null; return (
{blocks.map((block, i) => ( - + ))} + {blocks.length === 0 && caret}
); } +function StreamingCaret() { + return ( + + ); +} + /* ------------------------------------------------------------------ */ /* Types */ /* ------------------------------------------------------------------ */ @@ -58,7 +86,11 @@ function parseBlocks(text: string): BlockNode[] { // Heading const headingMatch = line.match(/^(#{1,4})\s+(.+)/); if (headingMatch) { - blocks.push({ type: "heading", level: headingMatch[1].length, content: headingMatch[2] }); + blocks.push({ + type: "heading", + level: headingMatch[1].length, + content: headingMatch[2], + }); i++; continue; } @@ -124,12 +156,23 @@ function parseBlocks(text: string): BlockNode[] { /* Block renderer */ /* ------------------------------------------------------------------ */ -function Block({ block, highlightTerms }: { block: BlockNode; highlightTerms?: string[] }) { +function Block({ + block, + highlightTerms, + caret, +}: { + block: BlockNode; + highlightTerms?: string[]; + caret?: ReactNode; +}) { switch (block.type) { case "code": return (
-          {block.content}
+          
+            {block.content}
+            {caret}
+          
         
); @@ -141,25 +184,46 @@ function Block({ block, highlightTerms }: { block: BlockNode; highlightTerms?: s h3: "text-sm font-semibold", h4: "text-sm font-medium", }; - return ; + return ( + + + {caret} + + ); } case "hr": - return
; + return ( + <> +
+ {caret} + + ); case "list": { const Tag = block.ordered ? "ol" : "ul"; + const last = block.items.length - 1; return ( - + {block.items.map((item, i) => ( -
  • +
  • + + {i === last ? caret : null} +
  • ))}
    ); } case "paragraph": - return

    ; + return ( +

    + + {caret} +

    + ); } } @@ -178,7 +242,8 @@ type InlineNode = function parseInline(text: string): InlineNode[] { const nodes: InlineNode[] = []; // Pattern priority: code > link > bold > italic > bare URL > line break - const pattern = /(`[^`]+`)|(\[([^\]]+)\]\(([^)]+)\))|(\*\*([^*]+)\*\*)|(\*([^*]+)\*)|(\bhttps?:\/\/[^\s<>)\]]+)|(\n)/g; + const pattern = + /(`[^`]+`)|(\[([^\]]+)\]\(([^)]+)\))|(\*\*([^*]+)\*\*)|(\*([^*]+)\*)|(\bhttps?:\/\/[^\s<>)\]]+)|(\n)/g; let lastIndex = 0; let match: RegExpExecArray | null; @@ -217,7 +282,13 @@ function parseInline(text: string): InlineNode[] { return nodes; } -function InlineContent({ text, highlightTerms }: { text: string; highlightTerms?: string[] }) { +function InlineContent({ + text, + highlightTerms, +}: { + text: string; + highlightTerms?: string[]; +}) { const nodes = useMemo(() => parseInline(text), [text]); return ( @@ -225,17 +296,34 @@ function InlineContent({ text, highlightTerms }: { text: string; highlightTerms? {nodes.map((node, i) => { switch (node.type) { case "text": - return ; + return ( + + ); case "code": return ( - + {node.content} ); case "bold": - return ; + return ( + + + + ); case "italic": - return ; + return ( + + + + ); case "link": return ( {parts.map((part, i) => regex.test(part) ? ( - {part} + + {part} + ) : ( {part} - ) + ), )} ); diff --git a/web/src/components/ModelPickerDialog.tsx b/web/src/components/ModelPickerDialog.tsx new file mode 100644 index 000000000..13a7268ac --- /dev/null +++ b/web/src/components/ModelPickerDialog.tsx @@ -0,0 +1,392 @@ +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import type { GatewayClient } from "@/lib/gatewayClient"; +import { Check, Loader2, Search, X } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; + +/** + * Two-stage model picker modal. + * + * Mirrors ui-tui/src/components/modelPicker.tsx: + * Stage 1: pick provider (authenticated providers only) + * Stage 2: pick model within that provider + * + * On confirm, emits `/model --provider [--global]` through + * the parent callback so ChatPage can dispatch it via the existing slash + * pipeline. That keeps persistence + actual switch logic in one place. + */ + +interface ModelOptionProvider { + name: string; + slug: string; + models?: string[]; + total_models?: number; + is_current?: boolean; + warning?: string; +} + +interface ModelOptionsResponse { + model?: string; + provider?: string; + providers?: ModelOptionProvider[]; +} + +interface Props { + gw: GatewayClient; + sessionId: string; + onClose(): void; + /** Parent runs the resulting slash command through slashExec. */ + onSubmit(slashCommand: string): void; +} + +export function ModelPickerDialog({ gw, sessionId, onClose, onSubmit }: Props) { + const [providers, setProviders] = useState([]); + const [currentModel, setCurrentModel] = useState(""); + const [currentProviderSlug, setCurrentProviderSlug] = useState(""); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedSlug, setSelectedSlug] = useState(""); + const [selectedModel, setSelectedModel] = useState(""); + const [query, setQuery] = useState(""); + const [persistGlobal, setPersistGlobal] = useState(false); + const closedRef = useRef(false); + + // Load providers + models on open. + useEffect(() => { + closedRef.current = false; + + gw.request( + "model.options", + sessionId ? { session_id: sessionId } : {}, + ) + .then((r) => { + if (closedRef.current) return; + const next = r?.providers ?? []; + setProviders(next); + setCurrentModel(String(r?.model ?? "")); + setCurrentProviderSlug(String(r?.provider ?? "")); + setSelectedSlug( + (next.find((p) => p.is_current) ?? next[0])?.slug ?? "", + ); + setSelectedModel(""); + setLoading(false); + }) + .catch((e) => { + if (closedRef.current) return; + setError(e instanceof Error ? e.message : String(e)); + setLoading(false); + }); + + return () => { + closedRef.current = true; + }; + }, [gw, sessionId]); + + // Esc closes. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + onClose(); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onClose]); + + const selectedProvider = useMemo( + () => providers.find((p) => p.slug === selectedSlug) ?? null, + [providers, selectedSlug], + ); + + const models = useMemo( + () => selectedProvider?.models ?? [], + [selectedProvider], + ); + + const needle = query.trim().toLowerCase(); + + const filteredProviders = useMemo( + () => + !needle + ? providers + : providers.filter( + (p) => + p.name.toLowerCase().includes(needle) || + p.slug.toLowerCase().includes(needle) || + (p.models ?? []).some((m) => m.toLowerCase().includes(needle)), + ), + [providers, needle], + ); + + const filteredModels = useMemo( + () => + !needle ? models : models.filter((m) => m.toLowerCase().includes(needle)), + [models, needle], + ); + + const canConfirm = !!selectedProvider && !!selectedModel; + + const confirm = () => { + if (!canConfirm) return; + const global = persistGlobal ? " --global" : ""; + onSubmit( + `/model ${selectedModel} --provider ${selectedProvider.slug}${global}`, + ); + onClose(); + }; + + return ( +
    e.target === e.currentTarget && onClose()} + role="dialog" + aria-modal="true" + aria-labelledby="model-picker-title" + > +
    + + +
    +

    + Switch Model +

    +

    + current: {currentModel || "(unknown)"} + {currentProviderSlug && ` · ${currentProviderSlug}`} +

    +
    + +
    +
    + + setQuery(e.target.value)} + className="pl-7 h-8 text-sm" + /> +
    +
    + +
    + { + setSelectedSlug(slug); + setSelectedModel(""); + }} + /> + + { + setSelectedModel(m); + // Confirm on next tick so state settles. + window.setTimeout(confirm, 0); + }} + /> +
    + +
    + + +
    + + +
    +
    +
    +
    + ); +} + +/* ------------------------------------------------------------------ */ +/* Provider column */ +/* ------------------------------------------------------------------ */ + +function ProviderColumn({ + loading, + error, + providers, + total, + selectedSlug, + query, + onSelect, +}: { + loading: boolean; + error: string | null; + providers: ModelOptionProvider[]; + total: number; + selectedSlug: string; + query: string; + onSelect(slug: string): void; +}) { + return ( +
    + {loading && ( +
    + loading… +
    + )} + + {error &&
    {error}
    } + + {!loading && !error && providers.length === 0 && ( +
    + {query + ? "no matches" + : total === 0 + ? "no authenticated providers" + : "no matches"} +
    + )} + + {providers.map((p) => { + const active = p.slug === selectedSlug; + return ( + + ); + })} +
    + ); +} + +/* ------------------------------------------------------------------ */ +/* Model column */ +/* ------------------------------------------------------------------ */ + +function ModelColumn({ + provider, + models, + allModels, + selectedModel, + currentModel, + currentProviderSlug, + onSelect, + onConfirm, +}: { + provider: ModelOptionProvider | null; + models: string[]; + allModels: string[]; + selectedModel: string; + currentModel: string; + currentProviderSlug: string; + onSelect(model: string): void; + onConfirm(model: string): void; +}) { + if (!provider) { + return ( +
    +
    + pick a provider → +
    +
    + ); + } + + return ( +
    + {provider.warning && ( +
    + {provider.warning} +
    + )} + + {models.length === 0 ? ( +
    + {allModels.length + ? "no models match your filter" + : "no models listed for this provider"} +
    + ) : ( + models.map((m) => { + const active = m === selectedModel; + const isCurrent = + m === currentModel && provider.slug === currentProviderSlug; + + return ( + + ); + }) + )} +
    + ); +} + +function CurrentTag() { + return ( + + current + + ); +} diff --git a/web/src/components/SlashPopover.tsx b/web/src/components/SlashPopover.tsx new file mode 100644 index 000000000..1c4b273b3 --- /dev/null +++ b/web/src/components/SlashPopover.tsx @@ -0,0 +1,174 @@ +import type { GatewayClient } from "@/lib/gatewayClient"; +import { ChevronRight } from "lucide-react"; +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useRef, + useState, +} from "react"; + +/** + * Slash-command autocomplete popover, rendered above the composer in ChatPage. + * Mirrors the completion UX of the Ink TUI — type `/`, see matching commands, + * arrow keys or click to select, Tab to apply, Enter to submit. + * + * The parent owns all keyboard handling via `ref.handleKey`, which returns + * true when the popover consumed the event, so the composer's Enter/arrow + * logic stays in one place. + */ + +export interface CompletionItem { + display: string; + text: string; + meta?: string; +} + +export interface SlashPopoverHandle { + /** Returns true if the key was consumed by the popover. */ + handleKey(e: React.KeyboardEvent): boolean; +} + +interface Props { + input: string; + gw: GatewayClient | null; + onApply(nextInput: string): void; +} + +interface CompletionResponse { + items?: CompletionItem[]; + replace_from?: number; +} + +const DEBOUNCE_MS = 60; + +export const SlashPopover = forwardRef( + function SlashPopover({ input, gw, onApply }, ref) { + const [items, setItems] = useState([]); + const [selected, setSelected] = useState(0); + const [replaceFrom, setReplaceFrom] = useState(1); + const lastInputRef = useRef(""); + + // Debounced completion fetch. We never clear `items` in the effect body + // (doing so would flag react-hooks/set-state-in-effect); instead the + // render guard below hides stale items once the input stops matching. + useEffect(() => { + const trimmed = input ?? ""; + + if (!gw || !trimmed.startsWith("/") || trimmed === lastInputRef.current) { + if (!trimmed.startsWith("/")) lastInputRef.current = ""; + return; + } + lastInputRef.current = trimmed; + + const timer = window.setTimeout(async () => { + if (lastInputRef.current !== trimmed) return; + try { + const r = await gw.request("complete.slash", { + text: trimmed, + }); + if (lastInputRef.current !== trimmed) return; + setItems(r?.items ?? []); + setReplaceFrom(r?.replace_from ?? 1); + setSelected(0); + } catch { + if (lastInputRef.current === trimmed) setItems([]); + } + }, DEBOUNCE_MS); + + return () => window.clearTimeout(timer); + }, [input, gw]); + + const apply = useCallback( + (item: CompletionItem) => { + onApply(input.slice(0, replaceFrom) + item.text); + }, + [input, replaceFrom, onApply], + ); + + // Only consume keys when the popover is actually visible. Stale items from + // a previous slash prefix are ignored once the user deletes the "/". + const visible = items.length > 0 && input.startsWith("/"); + + useImperativeHandle( + ref, + () => ({ + handleKey: (e) => { + if (!visible) return false; + + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setSelected((s) => (s + 1) % items.length); + return true; + + case "ArrowUp": + e.preventDefault(); + setSelected((s) => (s - 1 + items.length) % items.length); + return true; + + case "Tab": { + e.preventDefault(); + const item = items[selected]; + if (item) apply(item); + return true; + } + + case "Escape": + e.preventDefault(); + setItems([]); + return true; + + default: + return false; + } + }, + }), + [visible, items, selected, apply], + ); + + if (!visible) return null; + + return ( +
    + {items.map((it, i) => { + const active = i === selected; + + return ( + + ); + })} +
    + ); + }, +); diff --git a/web/src/components/ToolCall.tsx b/web/src/components/ToolCall.tsx new file mode 100644 index 000000000..8ac1ebce6 --- /dev/null +++ b/web/src/components/ToolCall.tsx @@ -0,0 +1,228 @@ +import { + AlertCircle, + Check, + ChevronDown, + ChevronRight, + Zap, +} from "lucide-react"; +import { useEffect, useState } from "react"; + +/** + * Expandable tool call row — the web equivalent of Ink's ToolTrail node. + * + * Renders one `tool.start` + `tool.complete` pair (plus any `tool.progress` + * in between) as a single collapsible item in the transcript: + * + * ▸ ● read_file(path=/foo) 2.3s + * + * Click the header to reveal a preformatted body with context (args), the + * streaming preview (while running), and the final summary or error. Error + * rows auto-expand so failures aren't silently collapsed. + */ + +export interface ToolEntry { + kind: "tool"; + id: string; + tool_id: string; + name: string; + context?: string; + preview?: string; + summary?: string; + error?: string; + inline_diff?: string; + status: "running" | "done" | "error"; + startedAt: number; + completedAt?: number; +} + +const STATUS_TONE: Record = { + running: "border-primary/40 bg-primary/[0.04]", + done: "border-border bg-muted/20", + error: "border-destructive/50 bg-destructive/[0.04]", +}; + +const BULLET_TONE: Record = { + running: "text-primary", + done: "text-primary/80", + error: "text-destructive", +}; + +const TICK_MS = 500; + +export function ToolCall({ tool }: { tool: ToolEntry }) { + // `open` is derived: errors default-expanded, everything else collapsed. + // `null` means "follow the default"; any explicit bool is the user's override. + // This lets a running tool flip to expanded automatically when it errors, + // without mirroring state in an effect. + const [userOverride, setUserOverride] = useState(null); + const open = userOverride ?? tool.status === "error"; + + // Tick `now` while the tool is running so the elapsed label updates live. + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + if (tool.status !== "running") return; + const id = window.setInterval(() => setNow(() => Date.now()), TICK_MS); + return () => window.clearInterval(id); + }, [tool.status]); + + // Historical tools (hydrated from session.resume) signal missing timestamps + // with `startedAt === 0`; we hide the elapsed badge for those rather than + // rendering a misleading "0ms". + const hasTimestamps = tool.startedAt > 0; + const elapsed = hasTimestamps + ? fmtElapsed((tool.completedAt ?? now) - tool.startedAt) + : null; + + const hasBody = !!( + tool.context || + tool.preview || + tool.summary || + tool.error || + tool.inline_diff + ); + + const Chevron = open ? ChevronDown : ChevronRight; + + return ( +
    + + + {open && hasBody && ( +
    + {tool.context &&
    {tool.context}
    } + + {tool.preview && tool.status === "running" && ( +
    + {tool.preview} + +
    + )} + + {tool.inline_diff && ( +
    +
    +                {colorizeDiff(tool.inline_diff)}
    +              
    +
    + )} + + {tool.summary && ( +
    + + {tool.summary} + +
    + )} + + {tool.error && ( +
    + + {tool.error} + +
    + )} +
    + )} +
    + ); +} + +function Section({ + label, + children, + tone, +}: { + label: string; + children: React.ReactNode; + tone?: "error"; +}) { + return ( +
    + + {label} + + +
    {children}
    +
    + ); +} + +function fmtElapsed(ms: number): string { + const sec = Math.max(0, ms) / 1000; + if (sec < 1) return `${Math.round(ms)}ms`; + if (sec < 10) return `${sec.toFixed(1)}s`; + if (sec < 60) return `${Math.round(sec)}s`; + + const m = Math.floor(sec / 60); + const s = Math.round(sec % 60); + return s ? `${m}m ${s}s` : `${m}m`; +} + +/** Colorize unified-diff lines for the inline diff section. */ +function colorizeDiff(diff: string): React.ReactNode { + return diff.split("\n").map((line, i) => ( +
    + {line || "\u00A0"} +
    + )); +} + +function diffLineClass(line: string): string { + if (line.startsWith("+") && !line.startsWith("+++")) + return "text-emerald-500 dark:text-emerald-400"; + if (line.startsWith("-") && !line.startsWith("---")) + return "text-destructive"; + if (line.startsWith("@@")) return "text-primary"; + return "text-muted-foreground/80"; +} diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 352f6d730..c587bfc85 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -67,6 +67,7 @@ export const en: Translations = { }, nav: { analytics: "Analytics", + chat: "Chat", config: "Config", cron: "Cron", documentation: "Documentation", @@ -131,6 +132,7 @@ export const en: Translations = { "This permanently removes the conversation and all of its messages. This cannot be undone.", sessionDeleted: "Session deleted", failedToDelete: "Failed to delete session", + resumeInChat: "Resume in Chat", previousPage: "Previous page", nextPage: "Next page", roles: { diff --git a/web/src/i18n/types.ts b/web/src/i18n/types.ts index 45da160af..9fe254115 100644 --- a/web/src/i18n/types.ts +++ b/web/src/i18n/types.ts @@ -67,6 +67,7 @@ export interface Translations { }; nav: { analytics: string; + chat: string; config: string; cron: string; documentation: string; @@ -132,6 +133,7 @@ export interface Translations { confirmDeleteMessage: string; sessionDeleted: string; failedToDelete: string; + resumeInChat: string; previousPage: string; nextPage: string; roles: { diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index b7df4e24a..58953ffb4 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -66,6 +66,7 @@ export const zh: Translations = { }, nav: { analytics: "分析", + chat: "对话", config: "配置", cron: "定时任务", documentation: "文档", @@ -129,6 +130,7 @@ export const zh: Translations = { confirmDeleteMessage: "此操作将永久删除对话及其所有消息,无法恢复。", sessionDeleted: "会话已删除", failedToDelete: "删除会话失败", + resumeInChat: "在对话中继续", previousPage: "上一页", nextPage: "下一页", roles: { diff --git a/web/src/index.css b/web/src/index.css index 1e5e9cb9e..e9818174e 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -5,6 +5,36 @@ Tailwind's JIT purge. */ @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 */ /* statically. Mirrors nousnet-web/(hermes-agent)/layout.tsx so the */ diff --git a/web/src/lib/gatewayClient.ts b/web/src/lib/gatewayClient.ts new file mode 100644 index 000000000..012482b71 --- /dev/null +++ b/web/src/lib/gatewayClient.ts @@ -0,0 +1,236 @@ +/** + * Browser WebSocket client for the tui_gateway JSON-RPC protocol. + * + * Speaks the exact same newline-delimited JSON-RPC dialect that the Ink TUI + * drives over stdio. The server-side transport abstraction + * (tui_gateway/transport.py + ws.py) routes the same dispatcher's writes + * onto either stdout or a WebSocket depending on how the client connected. + * + * const gw = new GatewayClient() + * await gw.connect() + * const { session_id } = await gw.request<{ session_id: string }>("session.create") + * gw.on("message.delta", (ev) => console.log(ev.payload?.text)) + * await gw.request("prompt.submit", { session_id, text: "hi" }) + */ + +export type GatewayEventName = + | "gateway.ready" + | "session.info" + | "message.start" + | "message.delta" + | "message.complete" + | "thinking.delta" + | "reasoning.delta" + | "reasoning.available" + | "status.update" + | "tool.start" + | "tool.progress" + | "tool.complete" + | "tool.generating" + | "clarify.request" + | "approval.request" + | "sudo.request" + | "secret.request" + | "background.complete" + | "btw.complete" + | "error" + | "skin.changed" + | (string & {}); + +export interface GatewayEvent

    { + type: GatewayEventName; + session_id?: string; + payload?: P; +} + +export type ConnectionState = + | "idle" + | "connecting" + | "open" + | "closed" + | "error"; + +interface Pending { + resolve: (v: unknown) => void; + reject: (e: Error) => void; + timer: ReturnType; +} + +const DEFAULT_REQUEST_TIMEOUT_MS = 120_000; + +/** Wildcard listener key: subscribe to every event regardless of type. */ +const ANY = "*"; + +export class GatewayClient { + private ws: WebSocket | null = null; + private reqId = 0; + private pending = new Map(); + private listeners = new Map void>>(); + private _state: ConnectionState = "idle"; + private stateListeners = new Set<(s: ConnectionState) => void>(); + + get state(): ConnectionState { + return this._state; + } + + private setState(s: ConnectionState) { + if (this._state === s) return; + this._state = s; + for (const cb of this.stateListeners) cb(s); + } + + onState(cb: (s: ConnectionState) => void): () => void { + this.stateListeners.add(cb); + cb(this._state); + return () => this.stateListeners.delete(cb); + } + + /** Subscribe to a specific event type. Returns an unsubscribe function. */ + on

    ( + type: GatewayEventName, + cb: (ev: GatewayEvent

    ) => void, + ): () => void { + let set = this.listeners.get(type); + if (!set) { + set = new Set(); + this.listeners.set(type, set); + } + set.add(cb as (ev: GatewayEvent) => void); + return () => set!.delete(cb as (ev: GatewayEvent) => void); + } + + /** Subscribe to every event (fires after type-specific listeners). */ + onAny(cb: (ev: GatewayEvent) => void): () => void { + return this.on(ANY as GatewayEventName, cb); + } + + async connect(token?: string): Promise { + if (this._state === "open" || this._state === "connecting") return; + this.setState("connecting"); + + const resolved = token ?? window.__HERMES_SESSION_TOKEN__ ?? ""; + if (!resolved) { + this.setState("error"); + throw new Error( + "Session token not available — page must be served by the Hermes dashboard", + ); + } + + const scheme = location.protocol === "https:" ? "wss:" : "ws:"; + const ws = new WebSocket( + `${scheme}//${location.host}/api/ws?token=${encodeURIComponent(resolved)}`, + ); + this.ws = ws; + + // Register message + close BEFORE awaiting open — the server emits + // `gateway.ready` immediately after accept, so a listener attached + // after the open promise resolves can race past it and drop the + // initial skin payload. + ws.addEventListener("message", (ev) => { + try { + this.dispatch(JSON.parse(ev.data)); + } catch { + /* malformed frame — ignore */ + } + }); + + ws.addEventListener("close", () => { + this.setState("closed"); + this.rejectAllPending(new Error("WebSocket closed")); + }); + + await new Promise((resolve, reject) => { + const onOpen = () => { + ws.removeEventListener("error", onError); + this.setState("open"); + resolve(); + }; + const onError = () => { + ws.removeEventListener("open", onOpen); + this.setState("error"); + reject(new Error("WebSocket connection failed")); + }; + ws.addEventListener("open", onOpen, { once: true }); + ws.addEventListener("error", onError, { once: true }); + }); + } + + close() { + this.ws?.close(); + this.ws = null; + } + + private dispatch(msg: Record) { + const id = msg.id as string | undefined; + + if (id !== undefined && this.pending.has(id)) { + const p = this.pending.get(id)!; + this.pending.delete(id); + clearTimeout(p.timer); + + const err = msg.error as { message?: string } | undefined; + if (err) p.reject(new Error(err.message ?? "request failed")); + else p.resolve(msg.result); + return; + } + + if (msg.method !== "event") return; + + const params = (msg.params ?? {}) as GatewayEvent; + if (typeof params.type !== "string") return; + + for (const cb of this.listeners.get(params.type) ?? []) cb(params); + for (const cb of this.listeners.get(ANY) ?? []) cb(params); + } + + private rejectAllPending(err: Error) { + for (const p of this.pending.values()) { + clearTimeout(p.timer); + p.reject(err); + } + this.pending.clear(); + } + + /** Send a JSON-RPC request. Rejects on error response or timeout. */ + request( + method: string, + params: Record = {}, + timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, + ): Promise { + if (!this.ws || this._state !== "open") { + return Promise.reject( + new Error(`gateway not connected (state=${this._state})`), + ); + } + + const id = `w${++this.reqId}`; + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + if (this.pending.delete(id)) { + reject(new Error(`request timed out: ${method}`)); + } + }, timeoutMs); + + this.pending.set(id, { + resolve: (v) => resolve(v as T), + reject, + timer, + }); + + try { + this.ws!.send(JSON.stringify({ jsonrpc: "2.0", id, method, params })); + } catch (e) { + clearTimeout(timer); + this.pending.delete(id); + reject(e instanceof Error ? e : new Error(String(e))); + } + }); + } +} + +declare global { + interface Window { + __HERMES_SESSION_TOKEN__?: string; + } +} diff --git a/web/src/lib/resolve-page-title.ts b/web/src/lib/resolve-page-title.ts index 7d8cfd1ba..00d2d1e6e 100644 --- a/web/src/lib/resolve-page-title.ts +++ b/web/src/lib/resolve-page-title.ts @@ -1,6 +1,7 @@ import type { Translations } from "@/i18n/types"; const BUILTIN: Record = { + "/chat": "chat", "/sessions": "sessions", "/analytics": "analytics", "/logs": "logs", diff --git a/web/src/lib/slashExec.ts b/web/src/lib/slashExec.ts new file mode 100644 index 000000000..c232f2aa4 --- /dev/null +++ b/web/src/lib/slashExec.ts @@ -0,0 +1,163 @@ +/** + * Slash command execution pipeline for the web chat. + * + * Mirrors the Ink TUI's createSlashHandler.ts: + * + * 1. Parse the command into `name` + `arg`. + * 2. Try `slash.exec` — covers every registry-backed command the terminal + * UI knows about (/help, /resume, /compact, /model, …). Output is + * rendered into the transcript. + * 3. If `slash.exec` errors (command rejected, unknown, or needs client + * behaviour), fall back to `command.dispatch` which returns a typed + * directive: `exec` | `plugin` | `alias` | `skill` | `send`. + * 4. Each directive is dispatched to the appropriate callback. + * + * Keeping the pipeline here (instead of inline in ChatPage) lets future + * clients (SwiftUI, Android) implement the same logic by reading the same + * contract. + */ + +import type { GatewayClient } from "@/lib/gatewayClient"; + +export interface SlashExecResponse { + output?: string; + warning?: string; +} + +export type CommandDispatchResponse = + | { type: "exec" | "plugin"; output?: string } + | { type: "alias"; target: string } + | { type: "skill"; name: string; message?: string } + | { type: "send"; message: string }; + +export interface SlashExecCallbacks { + /** Render a transcript system message. */ + sys(text: string): void; + /** Submit a user message to the agent (prompt.submit). */ + send(message: string): Promise | void; +} + +export interface SlashExecOptions { + /** Raw command including the leading slash (e.g. "/model opus-4.6"). */ + command: string; + /** Session id. If empty the call is still issued — some commands are session-less. */ + sessionId: string; + gw: GatewayClient; + callbacks: SlashExecCallbacks; +} + +export type SlashExecResult = "done" | "sent" | "error"; + +/** + * Run a slash command. Returns the terminal state so callers can decide + * whether to clear the composer, queue retries, etc. + */ +export async function executeSlash({ + command, + sessionId, + gw, + callbacks: { sys, send }, +}: SlashExecOptions): Promise { + const { name, arg } = parseSlash(command); + + if (!name) { + sys("empty slash command"); + return "error"; + } + + // Primary dispatcher. + try { + const r = await gw.request("slash.exec", { + command: command.replace(/^\/+/, ""), + session_id: sessionId, + }); + const body = r?.output || `/${name}: no output`; + sys(r?.warning ? `warning: ${r.warning}\n${body}` : body); + return "done"; + } catch { + /* fall through to command.dispatch */ + } + + try { + const d = parseCommandDispatch( + await gw.request("command.dispatch", { + name, + arg, + session_id: sessionId, + }), + ); + + if (!d) { + sys("error: invalid response: command.dispatch"); + return "error"; + } + + switch (d.type) { + case "exec": + case "plugin": + sys(d.output ?? "(no output)"); + return "done"; + + case "alias": + return executeSlash({ + command: `/${d.target}${arg ? ` ${arg}` : ""}`, + sessionId, + gw, + callbacks: { sys, send }, + }); + + case "skill": + case "send": { + const msg = d.message?.trim() ?? ""; + if (!msg) { + sys( + `/${name}: ${d.type === "skill" ? "skill payload missing message" : "empty message"}`, + ); + return "error"; + } + if (d.type === "skill") sys(`⚡ loading skill: ${d.name}`); + await send(msg); + return "sent"; + } + } + } catch (err) { + sys(`error: ${err instanceof Error ? err.message : String(err)}`); + return "error"; + } +} + +export function parseSlash(command: string): { name: string; arg: string } { + const m = command.replace(/^\/+/, "").match(/^(\S+)\s*(.*)$/); + return m ? { name: m[1], arg: m[2].trim() } : { name: "", arg: "" }; +} + +function parseCommandDispatch(raw: unknown): CommandDispatchResponse | null { + if (!raw || typeof raw !== "object") return null; + + const r = raw as Record; + const str = (v: unknown) => (typeof v === "string" ? v : undefined); + + switch (r.type) { + case "exec": + case "plugin": + return { type: r.type, output: str(r.output) }; + + case "alias": + return typeof r.target === "string" + ? { type: "alias", target: r.target } + : null; + + case "skill": + return typeof r.name === "string" + ? { type: "skill", name: r.name, message: str(r.message) } + : null; + + case "send": + return typeof r.message === "string" + ? { type: "send", message: r.message } + : null; + + default: + return null; + } +} diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx new file mode 100644 index 000000000..32c780b3e --- /dev/null +++ b/web/src/pages/ChatPage.tsx @@ -0,0 +1,474 @@ +/** + * ChatPage — embeds `hermes --tui` inside the dashboard. + * + *

    (dashboard chrome) . + * └─
    (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= . + * ▼ . + * FastAPI pty_ws (hermes_cli/web_server.py) . + * ▼ . + * POSIX PTY → `node ui-tui/dist/entry.js` → tui_gateway + AIAgent . + */ + +import { FitAddon } from "@xterm/addon-fit"; +import { Unicode11Addon } from "@xterm/addon-unicode11"; +import { WebLinksAddon } from "@xterm/addon-web-links"; +import { WebglAddon } from "@xterm/addon-webgl"; +import { Terminal } from "@xterm/xterm"; +import "@xterm/xterm/css/xterm.css"; +import { Copy } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useSearchParams } from "react-router-dom"; + +import { ChatSidebar } from "@/components/ChatSidebar"; + +function buildWsUrl( + token: string, + resume: string | null, + channel: string, +): string { + const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; + const qs = new URLSearchParams({ token, channel }); + if (resume) qs.set("resume", resume); + return `${proto}//${window.location.host}/api/pty?${qs.toString()}`; +} + +// Channel id ties this chat tab's PTY child (publisher) to its sidebar +// (subscriber). Generated once per mount so a tab refresh starts a fresh +// channel — the previous PTY child terminates with the old WS, and its +// channel auto-evicts when no subscribers remain. +function generateChannelId(): string { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return crypto.randomUUID(); + } + return `chat-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}`; +} + +// 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(null); + const termRef = useRef(null); + const fitRef = useRef(null); + const wsRef = useRef(null); + const [searchParams] = useSearchParams(); + // Lazy-init: the missing-token check happens at construction so the effect + // body doesn't have to setState (React 19's set-state-in-effect rule). + const [banner, setBanner] = useState(() => + typeof window !== "undefined" && !window.__HERMES_SESSION_TOKEN__ + ? "Session token unavailable. Open this page through `hermes dashboard`, not directly." + : null, + ); + const [copyState, setCopyState] = useState<"idle" | "copied">("idle"); + const copyResetRef = useRef | null>(null); + + const resumeRef = useRef(searchParams.get("resume")); + const channel = useMemo(() => generateChannelId(), []); + + 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__; + // Banner already initialised above; just bail before wiring xterm/WS. + if (!token) { + 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: ";" + 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, channel); + 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. + // eslint-disable-next-line no-control-regex -- intentional ESC byte in xterm SGR mouse report parser + 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; + } + }; + }, [channel]); + + // Layout: + // outer flex column — sits inside the dashboard's content area + // row split — terminal pane (flex-1) + sidebar (fixed width, lg+) + // 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. + // sidebar — ChatSidebar opens its own JSON-RPC sidecar; renders + // model badge, tool-call list, model picker. Best-effort: if the + // sidecar fails to connect the terminal pane keeps working. + // + // `normal-case` opts out of the dashboard's global `uppercase` rule on + // the root `
    ` in App.tsx — terminal output must preserve case. + return ( +
    + {banner && ( +
    + {banner} +
    + )} +
    +
    +
    + + +
    + +
    + +
    +
    +
    + ); +} + +declare global { + interface Window { + __HERMES_SESSION_TOKEN__?: string; + } +} diff --git a/web/src/pages/SessionsPage.tsx b/web/src/pages/SessionsPage.tsx index ad6bb74ce..6e93b2583 100644 --- a/web/src/pages/SessionsPage.tsx +++ b/web/src/pages/SessionsPage.tsx @@ -1,4 +1,11 @@ -import { useEffect, useLayoutEffect, useState, useCallback, useRef } from "react"; +import { + useEffect, + useLayoutEffect, + useState, + useCallback, + useRef, +} from "react"; +import { useNavigate } from "react-router-dom"; import { AlertTriangle, CheckCircle2, @@ -16,6 +23,7 @@ import { MessageCircle, Hash, X, + Play, } from "lucide-react"; import { api } from "@/lib/api"; import type { @@ -262,6 +270,7 @@ function SessionRow({ const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const { t } = useI18n(); + const navigate = useNavigate(); useEffect(() => { if (isExpanded && messages === null && !loading) { @@ -341,6 +350,19 @@ function SessionRow({ {session.source ?? "local"} +